├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── build.yml ├── .gitignore ├── .jazzy.yaml ├── .swift-format ├── Guides ├── Getting Started.md ├── Images │ └── architecture.jpg ├── Installation.md └── Persisting State.md ├── LICENSE ├── Mintfile ├── Package.swift ├── README.md ├── Sources ├── SwiftDux │ ├── Action │ │ ├── Action.swift │ │ ├── ActionDispatcher.swift │ │ ├── ActionDispatcherProxy.swift │ │ ├── ActionPlan.swift │ │ ├── ActionSubscriber.swift │ │ ├── CompositeAction.swift │ │ └── RunnableAction.swift │ ├── Middleware │ │ ├── CompositeMiddleware.swift │ │ ├── HandleActionMiddleware.swift │ │ ├── Middleware.swift │ │ └── ReducerMiddleware.swift │ ├── Reducer │ │ ├── CompositeReducer.swift │ │ └── Reducer.swift │ ├── State │ │ ├── IdentifiableState.swift │ │ ├── OrderedState.swift │ │ └── StateType.swift │ ├── Store │ │ ├── StateStorable.swift │ │ ├── Store.swift │ │ ├── StoreProxy.swift │ │ ├── StorePublisher.swift │ │ └── StoreReducer.swift │ └── UI │ │ ├── ActionBinder.swift │ │ ├── ActionBinding.swift │ │ ├── Connectable.swift │ │ ├── ConnectableView.swift │ │ ├── ConnectableViewModifier.swift │ │ ├── Connector.swift │ │ ├── Extensions │ │ ├── Environment+ActionDispatcher.swift │ │ └── Environment+AnyStore.swift │ │ ├── MappedDispatch.swift │ │ ├── MappedState.swift │ │ └── ViewModifiers │ │ ├── OnActionViewModifier.swift │ │ ├── OnAppearDispatchViewModifier.swift │ │ └── StoreProviderViewModifier.swift └── SwiftDuxExtras │ ├── Persistence │ ├── JSONStatePersistor.swift │ ├── LocalStatePersistentLocation.swift │ ├── PersistStateMiddleware.swift │ ├── PersistSubscriber.swift │ ├── StatePersistentLocation.swift │ └── StatePersistor.swift │ └── PrintActionMiddleware.swift ├── Tests ├── LinuxMain.swift └── SwiftDuxTests │ ├── Action │ └── ActionPlanTests.swift │ ├── Middleware │ └── CompositeMiddlwareTests.swift │ ├── PerformanceTests.swift │ ├── Reducer │ └── CompositeReducerTests.swift │ ├── State │ └── OrderedStateTests.swift │ ├── Store │ ├── StoreProxyTests.swift │ └── StoreTests.swift │ ├── StoreActionDispatcherTests.swift │ ├── SwiftDuxExtras │ ├── Persistence │ │ ├── JSONStatePersistorTests.swift │ │ └── PersistStateMiddlewareTests.swift │ └── PrintActionMiddlewareTests.swift │ ├── TestState │ ├── TestReducer.swift │ ├── TestState.swift │ ├── TodoList │ │ └── TodoListReducer.swift │ └── configureStore.swift │ ├── TodoExampleTests.swift │ ├── UI │ ├── ActionBinderTests.swift │ └── __Snapshots__ │ │ └── ConnectableViewTests │ │ └── testConnectableView.1.png │ └── XCTestManifests.swift ├── codecov.yml └── scripts ├── format.sh ├── jazzy-docs.sh └── lint.sh /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Platform** 27 | - OS: [e.g. iOS] 28 | - Version [e.g. 22] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - 'master' 5 | pull_request: 6 | branches: 7 | - '**' 8 | name: build 9 | jobs: 10 | validate: 11 | name: Validate 12 | runs-on: macos-10.15 13 | strategy: 14 | matrix: 15 | destination: 16 | - "platform=iOS Simulator,OS=14.2,name=iPhone 12" 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@master 20 | - name: Switch to workspace directory 21 | run: cd $GITHUB_WORKSPACE 22 | - name: Install tooling 23 | run: | 24 | sudo xcode-select -s /Applications/Xcode_12.2.app 25 | brew install mint 26 | mint install apple/swift-format@0.50300.0 27 | - name: Check code formatting 28 | run: | 29 | ./scripts/lint.sh 30 | - name: Run tests 31 | run: | 32 | swift package generate-xcodeproj --enable-code-coverage 33 | xcodebuild -project SwiftDux.xcodeproj -scheme SwiftDux-Package -destination "${destination}" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO test 34 | bash <(curl -s https://codecov.io/bash) -J 'SwiftDux' 35 | env: 36 | destination: ${{ matrix.destination }} 37 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | ## Playgrounds 32 | timeline.xctimeline 33 | playground.xcworkspace 34 | 35 | # Swift Package Manager 36 | # 37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 38 | # Packages/ 39 | # Package.pins 40 | # Package.resolved 41 | .build/ 42 | 43 | # CocoaPods 44 | # 45 | # We recommend against adding the Pods directory to your .gitignore. However 46 | # you should judge for yourself, the pros and cons are mentioned at: 47 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 48 | # 49 | # Pods/ 50 | 51 | # Carthage 52 | # 53 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 54 | # Carthage/Checkouts 55 | 56 | Carthage/Build 57 | 58 | # fastlane 59 | # 60 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 61 | # screenshots whenever they are needed. 62 | # For more information about the recommended setup visit: 63 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 64 | 65 | fastlane/report.xml 66 | fastlane/Preview.html 67 | fastlane/screenshots/**/*.png 68 | fastlane/test_output 69 | .DS_Store 70 | .swiftpm 71 | SwiftDux.xcodeproj 72 | docs/ -------------------------------------------------------------------------------- /.jazzy.yaml: -------------------------------------------------------------------------------- 1 | github_url: https://github.com/StevenLambion/SwiftDux 2 | documentation: 3 | - Guides/*.md 4 | custom_categories: 5 | - name: "Documentation" 6 | children: 7 | - "Installation" 8 | - "Getting Started" 9 | - "Persisting State" 10 | - name: State 11 | children: 12 | - StateType 13 | - IdentifiableState 14 | - OrderedState 15 | - name: Actions 16 | children: 17 | - Action 18 | - ActionPlan 19 | - ActionDispatcher 20 | - RunnableAction 21 | - EmptyAction 22 | - SendAction 23 | - name: Store 24 | children: 25 | - Store 26 | - StoreProxy 27 | - StoreAction 28 | - name: Reducer 29 | children: 30 | - Reducer 31 | - CompositeReducer 32 | - name: Middleware 33 | children: 34 | - Middleware 35 | - CompositeMiddleware 36 | - HandleActionMiddleware 37 | - name: SwiftUI 38 | children: 39 | - View 40 | - MappedState 41 | - MappedDispatch 42 | - ConnectableView 43 | - ActionBinder 44 | - ActionBinding 45 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "lineLength": 160, 4 | "indentation": { 5 | "spaces": 2 6 | }, 7 | "lineBreakBeforeControlFlowKeywords": false, 8 | "lineBreakBeforeEachArgument": true, 9 | "indentConditionalCompilationBlocks": true, 10 | "blankLineBetweenMembers": { 11 | "ignoreSingleLineProperties": true 12 | }, 13 | "rules" : { 14 | "AllPublicDeclarationsHaveDocumentation" : false, 15 | "AlwaysUseLowerCamelCase" : true, 16 | "AmbiguousTrailingClosureOverload" : true, 17 | "BeginDocumentationCommentWithOneLineSummary" : false, 18 | "BlankLineBetweenMembers" : false, 19 | "CaseIndentLevelEqualsSwitch" : true, 20 | "DoNotUseSemicolons" : true, 21 | "DontRepeatTypeInStaticProperties" : true, 22 | "FullyIndirectEnum" : true, 23 | "GroupNumericLiterals" : true, 24 | "IdentifiersMustBeASCII" : true, 25 | "MultiLineTrailingCommas" : true, 26 | "NeverForceUnwrap" : false, 27 | "NeverUseForceTry" : false, 28 | "NeverUseImplicitlyUnwrappedOptionals" : true, 29 | "NoAccessLevelOnExtensionDeclaration" : true, 30 | "NoBlockComments" : true, 31 | "NoCasesWithOnlyFallthrough" : true, 32 | "NoEmptyTrailingClosureParentheses" : true, 33 | "NoLabelsInCasePatterns" : true, 34 | "NoLeadingUnderscores" : true, 35 | "NoParensAroundConditions" : true, 36 | "NoVoidReturnOnFunctionSignature" : true, 37 | "OneCasePerLine" : true, 38 | "OneVariableDeclarationPerLine" : true, 39 | "OnlyOneTrailingClosureArgument" : true, 40 | "OrderedImports" : true, 41 | "ReturnVoidInsteadOfEmptyTuple" : true, 42 | "UseEnumForNamespacing" : true, 43 | "UseLetInEveryBoundCaseVariable" : true, 44 | "UseShorthandTypeNames" : true, 45 | "UseSingleLinePropertyGetter" : true, 46 | "UseSynthesizedInitializer" : false, 47 | "UseTripleSlashForDocumentationComments" : true, 48 | "ValidateDocumentationComments" : true 49 | }, 50 | 51 | } -------------------------------------------------------------------------------- /Guides/Getting Started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | SwiftDux helps build SwiftUI-based applications around an [elm-like architecture](https://guide.elm-lang.org/architecture/) using a single, centralized state container. It has 4 basic constructs: 4 | 5 | - **State** - An immutable, single source of truth within the application. 6 | - **Action** - Describes a single change of the state. 7 | - **Reducer** - Returns a new state by consuming the previous one with an action. 8 | - **View** - The visual representation of the current state. 9 | 10 |
11 | 12 |
13 | 14 | ## State 15 | 16 | The state is an immutable structure acting as the single source of truth within the application. 17 | 18 | Below is an example of a todo app's state. It has a root `AppState` as well as an ordered list of `TodoItem` objects. 19 | 20 | ```swift 21 | import SwiftDux 22 | 23 | typealias StateType = Equatable & Codable 24 | 25 | struct AppState: StateType { 26 | todos: OrderedState 27 | } 28 | 29 | struct TodoItem: StateType, Identifiable { 30 | var id: String, 31 | var text: String 32 | } 33 | ``` 34 | 35 | ## Actions 36 | 37 | An action is a dispatched event to mutate the application's state. Swift's enum type is ideal for actions, but structs and classes could be used as well. 38 | 39 | ```swift 40 | import SwiftDux 41 | 42 | enum TodoAction: Action { 43 | case addTodo(text: String) 44 | case removeTodos(at: IndexSet) 45 | case moveTodos(from: IndexSet, to: Int) 46 | } 47 | ``` 48 | 49 | ## Reducers 50 | 51 | A reducer consumes an action to produce a new state. 52 | 53 | ```swift 54 | final class TodosReducer: Reducer { 55 | 56 | func reduce(state: AppState, action: TodoAction) -> AppState { 57 | var state = state 58 | switch action { 59 | case .addTodo(let text): 60 | let id = UUID().uuidString 61 | state.todos.append(TodoItemState(id: id, text: text)) 62 | case .removeTodos(let indexSet): 63 | state.todos.remove(at: indexSet) 64 | case .moveTodos(let indexSet, let index): 65 | state.todos.move(from: indexSet, to: index) 66 | } 67 | return state 68 | } 69 | } 70 | ``` 71 | 72 | ## Store 73 | 74 | The store manages the state and notifies the views of any updates. 75 | 76 | ```swift 77 | import SwiftDux 78 | 79 | let store = Store( 80 | state: AppState(todos: OrderedState()), 81 | reducer: AppReducer() 82 | ) 83 | 84 | window.rootViewController = UIHostingController( 85 | rootView: RootView().provideStore(store) 86 | ) 87 | ``` 88 | 89 | ## Middleware 90 | SwiftDux supports middleware to extend functionality. The SwiftDuxExtras module provides two built-in middleware to get started: 91 | 92 | - `PersistStateMiddleware` persists and restores the application state between sessions. 93 | - `PrintActionMiddleware` prints out each dispatched action for debugging purposes. 94 | 95 | ```swift 96 | import SwiftDux 97 | 98 | let store = Store( 99 | state: AppState(todos: OrderedState()), 100 | reducer: AppReducer(), 101 | middleware: PrintActionMiddleware()) 102 | ) 103 | 104 | window.rootViewController = UIHostingController( 105 | rootView: RootView().provideStore(store) 106 | ) 107 | ``` 108 | 109 | ## Composing Reducers, Middleware, and Actions 110 | You may compose a set of reducers, actions, or middleware into an ordered chain using the '+' operator. 111 | 112 | ```swift 113 | // Break up an application into smaller modules by composing reducers. 114 | let rootReducer = AppReducer() + NavigationReducer() 115 | 116 | // Add multiple middleware together. 117 | let middleware = 118 | PrintActionMiddleware() + 119 | PersistStateMiddleware(JSONStatePersistor() 120 | 121 | let store = Store( 122 | state: AppState(todos: OrderedState()), 123 | reducer: reducer, 124 | middleware: middleware 125 | ) 126 | ``` 127 | 128 | ## ConnectableView 129 | 130 | The `ConnectableView` protocol provides a slice of the application state to your views using the functions `map(state:)` or `map(state:binder:)`. It automatically updates the view when the props value has changed. 131 | 132 | ```swift 133 | struct TodosView: ConnectableView { 134 | struct Props: Equatable { 135 | var todos: [TodoItem] 136 | } 137 | 138 | func map(state: AppState) -> Props? { 139 | Props(todos: state.todos) 140 | } 141 | 142 | func body(props: OrderedState): some View { 143 | List { 144 | ForEach(todos) { todo in 145 | TodoItemRow(item: todo) 146 | } 147 | } 148 | } 149 | } 150 | ``` 151 | 152 | ## ActionBinding<_> 153 | 154 | Use the `map(state:binder:)` method on the `ConnectableView` protocol to bind an action to the props object. It can also be used to bind an updatable state value with an action. 155 | 156 | ```swift 157 | struct TodosView: ConnectableView { 158 | struct Props: Equatable { 159 | var todos: [TodoItem] 160 | @ActionBinding var newTodoText: String 161 | @ActionBinding var addTodo: () -> () 162 | } 163 | 164 | func map(state: AppState, binder: ActionBinder) -> OrderedState? { 165 | Props( 166 | todos: state.todos, 167 | newTodoText: binder.bind(state.newTodoText) { TodoAction.setNewTodoText($0) }, 168 | addTodo: binder.bind { TodoAction.addTodo() } 169 | ) 170 | } 171 | 172 | func body(props: OrderedState): some View { 173 | List { 174 | TextField("New Todo", text: props.$newTodoText, onCommit: props.addTodo) 175 | ForEach(todos) { todo in 176 | TodoItemRow(item: todo) 177 | } 178 | } 179 | } 180 | } 181 | ``` 182 | 183 | ## Action Plans 184 | An `ActionPlan` is a special kind of action that can be used to group other actions together or perform any kind of async logic outside of a reducer. It's also useful for actions that may require information about the state before it can be dispatched. 185 | 186 | ```swift 187 | /// Dispatch multiple actions after checking the current state of the application. 188 | let plan = ActionPlan { store in 189 | guard store.state.someValue == nil else { return } 190 | store.send(actionA) 191 | store.send(actionB) 192 | store.send(actionC) 193 | } 194 | 195 | /// Subscribe to services and return a publisher that sends actions to the store. 196 | let plan = ActionPlan { store in 197 | userLocationService 198 | .publisher 199 | .map { LocationAction.updateUserLocation($0) } 200 | } 201 | ``` 202 | 203 | ## Action Dispatching 204 | You can access the `ActionDispatcher` of the store through the environment values. This allows you to dispatch actions from any view. 205 | 206 | ```swift 207 | struct MyView: View { 208 | @Environment(\.actionDispatcher) private var dispatch 209 | 210 | var body: some View { 211 | MyForm.onAppear { dispatch(FormAction.prepare) } 212 | } 213 | } 214 | ``` 215 | 216 | If it's an ActionPlan that's meant to be kept alive through a publisher, then you'll want to send it as a cancellable. The action below subscribes 217 | to the store, so it can keep a list of albums updated when the user applies different queries. 218 | 219 | ```swift 220 | extension AlbumListAction { 221 | var updateAlbumList: Action { 222 | ActionPlan { store in 223 | store 224 | .publish { $0.albumList.query } 225 | .debounce(for: .seconds(1), scheduler: RunLoop.main) 226 | .map { AlbumService.all(query: $0) } 227 | .switchToLatest() 228 | .catch { Just(AlbumListAction.setError($0) } 229 | .map { AlbumListAction.setAlbums($0) } 230 | } 231 | } 232 | } 233 | 234 | struct AlbumListContainer: ConnectableView { 235 | @Environment(\.actionDispatcher) private var dispatch 236 | @State private var cancellable: Cancellable? = nil 237 | 238 | func map(state: AppState) -> [Album]? { 239 | state.albumList.albums 240 | } 241 | 242 | func body(props: [Album]) -> some View { 243 | AlbumsList(albums: props).onAppear { 244 | cancellable = dispatch.sendAsCancellable(AlbumListAction.updateAlbumList) 245 | } 246 | } 247 | } 248 | ``` 249 | 250 | The above can be further simplified by using the built-in `onAppear(dispatch:)` method instead. This method not only dispatches regular actions, but it automatically handles cancellable ones. By default, the action will cancel itself when the view is destroyed. 251 | 252 | ```swift 253 | struct AlbumListContainer: ConnectableView { 254 | 255 | func map(state: AppState) -> [Album]? { 256 | Props(state.albumList.albums) 257 | } 258 | 259 | func body(props: [Album]) -> some View { 260 | AlbumsList(albums: props).onAppear(dispatch: AlbumListAction.updateAlbumList) 261 | } 262 | } 263 | ``` 264 | 265 | ## Previewing Connected Views 266 | To preview a connected view by itself use the `provideStore(_:)` method inside the preview. 267 | 268 | ```swift 269 | #if DEBUG 270 | public enum TodoRowContainer_Previews: PreviewProvider { 271 | static var store: Store { 272 | Store( 273 | state: TodoList( 274 | id: "1", 275 | name: "TodoList", 276 | todos: .init([ 277 | Todo(id: "1", text: "Get milk") 278 | ]) 279 | ), 280 | reducer: TodosReducer() 281 | ) 282 | } 283 | 284 | public static var previews: some View { 285 | TodoRowContainer(id: "1") 286 | .provideStore(store) 287 | } 288 | } 289 | #endif 290 | ``` 291 | -------------------------------------------------------------------------------- /Guides/Images/architecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StevenLambion/SwiftDux/99cb24dae97ab341b4be6183a01a9c79874d7de5/Guides/Images/architecture.jpg -------------------------------------------------------------------------------- /Guides/Installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Xcode 4 | 5 | Search for SwiftDux in Xcode's Swift Package Manager integration. 6 | 7 | ## Package.swift 8 | 9 | Include the library as a dependencies as shown below: 10 | 11 | ```swift 12 | import PackageDescription 13 | 14 | let package = Package( 15 | dependencies: [ 16 | .Package(url: "https://github.com/StevenLambion/SwiftDux.git", from: "2.0.0") 17 | ] 18 | ) 19 | ``` 20 | -------------------------------------------------------------------------------- /Guides/Persisting State.md: -------------------------------------------------------------------------------- 1 | # Persisting State 2 | 3 | SwiftDux provides a state persistence API in the `SwiftDuxExtras` module. The API is designed to be flexible for use in a variety of cases. The two typical purposes are saving the application state for the user and providing the latest state object to a debugging tool. 4 | 5 | ## Create a state persistor 6 | 7 | SwiftDuxExtras comes with a `StatePersistor` protocol to implement a new persistor type, but it also provides a concrete `JSONStatePersistor` class. 8 | 9 | ```swift 10 | import SwiftDuxExtras 11 | 12 | /// Initiate with no parameters to saves the state to a default location. 13 | 14 | let persistor = JSONStatePersistor() 15 | 16 | /// Initiate with `fileUrl` to saves the state to a a url of the local filesystem. 17 | 18 | let persistor = JSONStatePersistor(fileUrl: appDataUrl) 19 | 20 | /// Provide a custom `StatePersistenceLocation` for more flexibility. 21 | 22 | let persistor = JSONStatePersistor(location: remoteLocation) 23 | ``` 24 | 25 | ## Use middleware to set up state persistence 26 | 27 | SwiftDuxExtras provides a JSON-based implementation for general use cases. 28 | 29 | ```swift 30 | import SwiftDuxExtras 31 | 32 | let store = Store( 33 | state: AppState(), 34 | reducer: AppReducer(), 35 | middleware: PersistStateMiddleware(JSONStatePersistor()) 36 | ) 37 | 38 | ``` 39 | 40 | With the above code, the application will automatically persist the state when there's changes. The state will then be restored on application launch. 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Steven Lambion 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Mintfile: -------------------------------------------------------------------------------- 1 | apple/swift-format@0.50300.0 2 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SwiftDux", 7 | platforms: [ 8 | .iOS(.v14), 9 | .macOS(.v11), 10 | .watchOS(.v7), 11 | ], 12 | products: [ 13 | .library( 14 | name: "SwiftDux", 15 | targets: ["SwiftDux", "SwiftDuxExtras"]), 16 | ], 17 | targets: [ 18 | .target( 19 | name: "SwiftDux", 20 | dependencies: []), 21 | .target( 22 | name: "SwiftDuxExtras", 23 | dependencies: ["SwiftDux"]), 24 | .testTarget( 25 | name: "SwiftDuxTests", 26 | dependencies: [ 27 | "SwiftDux", 28 | "SwiftDuxExtras"]), 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftDux 2 | 3 | > Predictable state management for SwiftUI applications. 4 | 5 | [![Swift Version][swift-image]][swift-url] 6 | ![Platform Versions][ios-image] 7 | [![Github workflow][github-workflow-image]](https://github.com/StevenLambion/SwiftDux/actions) 8 | [![codecov][codecov-image]](https://codecov.io/gh/StevenLambion/SwiftDux) 9 | 10 | SwiftDux is a state container inspired by Redux and built on top of Combine and SwiftUI. It helps you write applications with predictable, consistent, and highly testable logic using a single source of truth. 11 | 12 | # Installation 13 | 14 | ## Prerequisites 15 | - Xcode 12+ 16 | - Swift 5.3+ 17 | - iOS 14+, macOS 11.0+, tvOS 14+, or watchOS 7+ 18 | 19 | ## Install via Xcode: 20 | 21 | Search for SwiftDux in Xcode's Swift Package Manager integration. 22 | 23 | ## Install via the Swift Package Manager: 24 | 25 | ```swift 26 | import PackageDescription 27 | 28 | let package = Package( 29 | dependencies: [ 30 | .Package(url: "https://github.com/StevenLambion/SwiftDux.git", from: "2.0.0") 31 | ] 32 | ) 33 | ``` 34 | 35 | # Demo Application 36 | 37 | Take a look at the [Todo Example App](https://github.com/StevenLambion/SwiftUI-Todo-Example) to see how SwiftDux works. 38 | 39 | # Getting Started 40 | 41 | SwiftDux helps build SwiftUI-based applications around an [elm-like architecture](https://guide.elm-lang.org/architecture/) using a single, centralized state container. It has 4 basic constructs: 42 | 43 | - **State** - An immutable, single source of truth within the application. 44 | - **Action** - Describes a single change of the state. 45 | - **Reducer** - Returns a new state by consuming the previous one with an action. 46 | - **View** - The visual representation of the current state. 47 | 48 |
49 | 50 |
51 | 52 | ## State 53 | 54 | The state is an immutable structure acting as the single source of truth within the application. 55 | 56 | Below is an example of a todo app's state. It has a root `AppState` as well as an ordered list of `TodoItem` objects. 57 | 58 | ```swift 59 | import SwiftDux 60 | 61 | typealias StateType = Equatable & Codable 62 | 63 | struct AppState: StateType { 64 | todos: OrderedState 65 | } 66 | 67 | struct TodoItem: StateType, Identifiable { 68 | var id: String, 69 | var text: String 70 | } 71 | ``` 72 | 73 | ## Actions 74 | 75 | An action is a dispatched event to mutate the application's state. Swift's enum type is ideal for actions, but structs and classes could be used as well. 76 | 77 | ```swift 78 | import SwiftDux 79 | 80 | enum TodoAction: Action { 81 | case addTodo(text: String) 82 | case removeTodos(at: IndexSet) 83 | case moveTodos(from: IndexSet, to: Int) 84 | } 85 | ``` 86 | 87 | ## Reducers 88 | 89 | A reducer consumes an action to produce a new state. 90 | 91 | ```swift 92 | final class TodosReducer: Reducer { 93 | 94 | func reduce(state: AppState, action: TodoAction) -> AppState { 95 | var state = state 96 | switch action { 97 | case .addTodo(let text): 98 | let id = UUID().uuidString 99 | state.todos.append(TodoItemState(id: id, text: text)) 100 | case .removeTodos(let indexSet): 101 | state.todos.remove(at: indexSet) 102 | case .moveTodos(let indexSet, let index): 103 | state.todos.move(from: indexSet, to: index) 104 | } 105 | return state 106 | } 107 | } 108 | ``` 109 | 110 | ## Store 111 | 112 | The store manages the state and notifies the views of any updates. 113 | 114 | ```swift 115 | import SwiftDux 116 | 117 | let store = Store( 118 | state: AppState(todos: OrderedState()), 119 | reducer: AppReducer() 120 | ) 121 | 122 | window.rootViewController = UIHostingController( 123 | rootView: RootView().provideStore(store) 124 | ) 125 | ``` 126 | 127 | ## Middleware 128 | SwiftDux supports middleware to extend functionality. The SwiftDuxExtras module provides two built-in middleware to get started: 129 | 130 | - `PersistStateMiddleware` persists and restores the application state between sessions. 131 | - `PrintActionMiddleware` prints out each dispatched action for debugging purposes. 132 | 133 | ```swift 134 | import SwiftDux 135 | 136 | let store = Store( 137 | state: AppState(todos: OrderedState()), 138 | reducer: AppReducer(), 139 | middleware: PrintActionMiddleware()) 140 | ) 141 | 142 | window.rootViewController = UIHostingController( 143 | rootView: RootView().provideStore(store) 144 | ) 145 | ``` 146 | 147 | ## Composing Reducers, Middleware, and Actions 148 | You may compose a set of reducers, actions, or middleware into an ordered chain using the '+' operator. 149 | 150 | ```swift 151 | // Break up an application into smaller modules by composing reducers. 152 | let rootReducer = AppReducer() + NavigationReducer() 153 | 154 | // Add multiple middleware together. 155 | let middleware = 156 | PrintActionMiddleware() + 157 | PersistStateMiddleware(JSONStatePersistor() 158 | 159 | let store = Store( 160 | state: AppState(todos: OrderedState()), 161 | reducer: reducer, 162 | middleware: middleware 163 | ) 164 | ``` 165 | 166 | ## ConnectableView 167 | 168 | The `ConnectableView` protocol provides a slice of the application state to your views using the functions `map(state:)` or `map(state:binder:)`. It automatically updates the view when the props value has changed. 169 | 170 | ```swift 171 | struct TodosView: ConnectableView { 172 | struct Props: Equatable { 173 | var todos: [TodoItem] 174 | } 175 | 176 | func map(state: AppState) -> Props? { 177 | Props(todos: state.todos) 178 | } 179 | 180 | func body(props: OrderedState): some View { 181 | List { 182 | ForEach(todos) { todo in 183 | TodoItemRow(item: todo) 184 | } 185 | } 186 | } 187 | } 188 | ``` 189 | 190 | ## ActionBinding<_> 191 | 192 | Use the `map(state:binder:)` method on the `ConnectableView` protocol to bind an action to the props object. It can also be used to bind an updatable state value with an action. 193 | 194 | ```swift 195 | struct TodosView: ConnectableView { 196 | struct Props: Equatable { 197 | var todos: [TodoItem] 198 | @ActionBinding var newTodoText: String 199 | @ActionBinding var addTodo: () -> () 200 | } 201 | 202 | func map(state: AppState, binder: ActionBinder) -> OrderedState? { 203 | Props( 204 | todos: state.todos, 205 | newTodoText: binder.bind(state.newTodoText) { TodoAction.setNewTodoText($0) }, 206 | addTodo: binder.bind { TodoAction.addTodo() } 207 | ) 208 | } 209 | 210 | func body(props: OrderedState): some View { 211 | List { 212 | TextField("New Todo", text: props.$newTodoText, onCommit: props.addTodo) 213 | ForEach(todos) { todo in 214 | TodoItemRow(item: todo) 215 | } 216 | } 217 | } 218 | } 219 | ``` 220 | 221 | ## Action Plans 222 | An `ActionPlan` is a special kind of action that can be used to group other actions together or perform any kind of async logic outside of a reducer. It's also useful for actions that may require information about the state before it can be dispatched. 223 | 224 | ```swift 225 | /// Dispatch multiple actions after checking the current state of the application. 226 | let plan = ActionPlan { store in 227 | guard store.state.someValue == nil else { return } 228 | store.send(actionA) 229 | store.send(actionB) 230 | store.send(actionC) 231 | } 232 | 233 | /// Subscribe to services and return a publisher that sends actions to the store. 234 | let plan = ActionPlan { store in 235 | userLocationService 236 | .publisher 237 | .map { LocationAction.updateUserLocation($0) } 238 | } 239 | ``` 240 | 241 | ## Action Dispatching 242 | You can access the `ActionDispatcher` of the store through the environment values. This allows you to dispatch actions from any view. 243 | 244 | ```swift 245 | struct MyView: View { 246 | @Environment(\.actionDispatcher) private var dispatch 247 | 248 | var body: some View { 249 | MyForm.onAppear { dispatch(FormAction.prepare) } 250 | } 251 | } 252 | ``` 253 | 254 | If it's an ActionPlan that's meant to be kept alive through a publisher, then you'll want to send it as a cancellable. The action below subscribes 255 | to the store, so it can keep a list of albums updated when the user applies different queries. 256 | 257 | ```swift 258 | extension AlbumListAction { 259 | var updateAlbumList: Action { 260 | ActionPlan { store in 261 | store 262 | .publish { $0.albumList.query } 263 | .debounce(for: .seconds(1), scheduler: RunLoop.main) 264 | .map { AlbumService.all(query: $0) } 265 | .switchToLatest() 266 | .catch { Just(AlbumListAction.setError($0) } 267 | .map { AlbumListAction.setAlbums($0) } 268 | } 269 | } 270 | } 271 | 272 | struct AlbumListContainer: ConnectableView { 273 | @Environment(\.actionDispatcher) private var dispatch 274 | @State private var cancellable: Cancellable? = nil 275 | 276 | func map(state: AppState) -> [Album]? { 277 | state.albumList.albums 278 | } 279 | 280 | func body(props: [Album]) -> some View { 281 | AlbumsList(albums: props).onAppear { 282 | cancellable = dispatch.sendAsCancellable(AlbumListAction.updateAlbumList) 283 | } 284 | } 285 | } 286 | ``` 287 | 288 | The above can be further simplified by using the built-in `onAppear(dispatch:)` method instead. This method not only dispatches regular actions, but it automatically handles cancellable ones. By default, the action will cancel itself when the view is destroyed. 289 | 290 | ```swift 291 | struct AlbumListContainer: ConnectableView { 292 | 293 | func map(state: AppState) -> [Album]? { 294 | Props(state.albumList.albums) 295 | } 296 | 297 | func body(props: [Album]) -> some View { 298 | AlbumsList(albums: props).onAppear(dispatch: AlbumListAction.updateAlbumList) 299 | } 300 | } 301 | ``` 302 | 303 | ## Previewing Connected Views 304 | To preview a connected view by itself use the `provideStore(_:)` method inside the preview. 305 | 306 | ```swift 307 | #if DEBUG 308 | public enum TodoRowContainer_Previews: PreviewProvider { 309 | static var store: Store { 310 | Store( 311 | state: TodoList( 312 | id: "1", 313 | name: "TodoList", 314 | todos: .init([ 315 | Todo(id: "1", text: "Get milk") 316 | ]) 317 | ), 318 | reducer: TodosReducer() 319 | ) 320 | } 321 | 322 | public static var previews: some View { 323 | TodoRowContainer(id: "1") 324 | .provideStore(store) 325 | } 326 | } 327 | #endif 328 | ``` 329 | 330 | [swift-image]: https://img.shields.io/badge/swift-5.3-orange.svg 331 | [ios-image]: https://img.shields.io/badge/platforms-iOS%2014%20%7C%20macOS%2011.0%20%7C%20tvOS%2014%20%7C%20watchOS%207-222.svg 332 | [swift-url]: https://swift.org/ 333 | [license-image]: https://img.shields.io/badge/License-MIT-blue.svg 334 | [license-url]: LICENSE 335 | [codebeat-image]: https://codebeat.co/badges/c19b47ea-2f9d-45df-8458-b2d952fe9dad 336 | [codebeat-url]: https://codebeat.co/projects/github-com-vsouza-awesomeios-com 337 | [github-workflow-image]: https://github.com/StevenLambion/SwiftDux/workflows/build/badge.svg 338 | [codecov-image]: https://codecov.io/gh/StevenLambion/SwiftDux/branch/master/graph/badge.svg 339 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Action/Action.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// A dispatchable action to update the application state. 5 | /// ``` 6 | /// enum TodoList : Action { 7 | /// case setItems(items: [TodoItem]) 8 | /// case addItem(withText: String) 9 | /// case removeItems(at: IndexSet) 10 | /// case moveItems(at: IndexSet, to: Int) 11 | /// } 12 | /// ``` 13 | public protocol Action {} 14 | 15 | extension Action { 16 | 17 | /// Chains an array of actions to be dispatched next. 18 | /// 19 | /// - Parameter actions: An array of actions to chain together. 20 | /// - Returns: A composite action. 21 | @inlinable public func then(_ actions: [Action]) -> CompositeAction { 22 | if var action = self as? CompositeAction { 23 | action.actions += actions 24 | return action 25 | } 26 | return CompositeAction([self] + actions) 27 | } 28 | 29 | /// Chains an array of actions to be dispatched next. 30 | /// 31 | /// - Parameter actions: One or more actions to chain together. 32 | /// - Returns: A composite action. 33 | @inlinable public func then(_ actions: Action...) -> CompositeAction { 34 | then(actions) 35 | } 36 | 37 | /// Call the provided block next. 38 | /// 39 | /// - Parameter block: A block of code to execute once the previous action has completed. 40 | /// - Returns: A composite action. 41 | @inlinable public func then(_ block: @escaping () -> Void) -> CompositeAction { 42 | then(ActionPlan { _ in block() }) 43 | } 44 | } 45 | 46 | /// A noop action used by reducers that may not have their own actions. 47 | public struct EmptyAction: Action { 48 | 49 | public init() {} 50 | } 51 | 52 | /// A closure that dispatches an action. 53 | /// 54 | /// - Parameter action: The action to dispatch. 55 | public typealias SendAction = (Action) -> Void 56 | 57 | /// A closure that dispatches a cancellable action. 58 | /// 59 | /// - Parameter action: The action to dispatch. 60 | public typealias SendCancellableAction = (Action) -> Cancellable 61 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Action/ActionDispatcher.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// An object that dispatches actions to a store. 5 | /// 6 | /// Once an action is sent, the sender shouldn't expect anything to occur. Instead, it should rely 7 | /// solely on changes to the state of the application to respond. 8 | public protocol ActionDispatcher { 9 | 10 | /// Sends an action to mutate the application state. 11 | /// 12 | /// - Parameter action: An action to dispatch to the store. 13 | func send(_ action: Action) 14 | 15 | /// Sends a cancellable action to mutate the application state. 16 | /// 17 | /// - Parameter action: An action to dispatch to the store. 18 | /// - Returns: A cancellable object. 19 | func sendAsCancellable(_ action: Action) -> Cancellable 20 | } 21 | 22 | extension ActionDispatcher { 23 | 24 | /// Sends an action to mutate the application state. 25 | /// 26 | /// - Parameter action: An action to dispatch to the store 27 | @inlinable public func callAsFunction(_ action: Action) { 28 | send(action) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Action/ActionDispatcherProxy.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// A concrete `ActionDispatcher` that can acts as a proxy. 5 | public struct ActionDispatcherProxy: ActionDispatcher { 6 | @usableFromInline internal var sendBlock: SendAction 7 | @usableFromInline internal var sendAsCancellableBlock: SendCancellableAction 8 | 9 | /// Initiate a new BlockActionDispatcher. 10 | /// 11 | /// - Parameters: 12 | /// - send: A closure to dispatch an action. 13 | /// - sendAsCancellable: A closure to dispatch a cancellable action. 14 | public init(send: @escaping SendAction, sendAsCancellable: @escaping SendCancellableAction) { 15 | self.sendBlock = send 16 | self.sendAsCancellableBlock = sendAsCancellable 17 | } 18 | 19 | @inlinable public func send(_ action: Action) { 20 | sendBlock(action) 21 | } 22 | 23 | @inlinable public func sendAsCancellable(_ action: Action) -> Cancellable { 24 | sendAsCancellableBlock(action) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Action/ActionPlan.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// Encapsulates external business logic outside of a reducer into a special kind of action. 5 | /// 6 | ///``` 7 | /// enum UserAction { 8 | /// 9 | /// static func loadUser(byId id: String) -> ActionPlan { 10 | /// ActionPlan { store in 11 | /// guard !store.state.users.hasValue(id) else { return nil } 12 | /// store.send(UserAction.setLoading(true)) 13 | /// return UserService.getUser(id) 14 | /// .first() 15 | /// .flatMap { user in 16 | /// [ 17 | /// UserAction.setUser(user) 18 | /// UserAction.setLoading(false) 19 | /// ].publisher 20 | /// } 21 | /// } 22 | /// } 23 | /// } 24 | /// } 25 | /// 26 | /// // Inside a view: 27 | /// 28 | /// func body(props: Props) -> some View { 29 | /// UserInfo(user: props.user) 30 | /// .onAppear { dispatch(UserAction.loadUser(byId: self.id)) } 31 | /// } 32 | ///```. 33 | public struct ActionPlan: RunnableAction { 34 | 35 | @usableFromInline internal typealias Body = (StoreProxy) -> AnyPublisher 36 | 37 | @usableFromInline 38 | internal var body: Body 39 | 40 | /// Initiate an action plan that returns a publisher of actions. 41 | /// 42 | /// - Parameter body: The body of the action plan. 43 | @inlinable public init

(_ body: @escaping (StoreProxy) -> P) where P: Publisher, P.Output == Action, P.Failure == Never { 44 | self.body = { store in body(store).eraseToAnyPublisher() } 45 | } 46 | 47 | /// Initiate an asynchronous action plan that completes after the first emitted void value from its publisher. 48 | /// 49 | /// Use this method to wrap asynchronous code in a publisher like `Future`. 50 | /// - Parameter body: The body of the action plan. 51 | @inlinable public init

(_ body: @escaping (StoreProxy) -> P) where P: Publisher, P.Output == Void, P.Failure == Never { 52 | self.body = { store in 53 | body(store) 54 | .first() 55 | .compactMap { _ -> Action? in nil } 56 | .eraseToAnyPublisher() 57 | } 58 | } 59 | 60 | /// Initiate a synchronous action plan. 61 | /// 62 | /// The plan expects to complete once the body has returned. 63 | /// - Parameter body: The body of the action plan. 64 | @inlinable public init(_ body: @escaping (StoreProxy) -> Void) { 65 | self.body = { store in 66 | body(store) 67 | return Empty().eraseToAnyPublisher() 68 | } 69 | } 70 | 71 | @inlinable public func run(store: StoreProxy) -> AnyPublisher { 72 | guard let storeProxy = store.proxy(for: State.self) else { 73 | fatalError("Store does not support type `\(State.self)` from ActionPlan.") 74 | } 75 | return body(storeProxy) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Action/ActionSubscriber.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// Subscribes to a publisher of actions, and sends them to an action dispatcher. 5 | final internal class ActionSubscriber: Subscriber { 6 | 7 | typealias ReceivedCompletion = () -> Void 8 | 9 | private let actionDispatcher: ActionDispatcher 10 | private var subscription: Subscription? = nil { 11 | willSet { 12 | guard let subscription = subscription else { return } 13 | subscription.cancel() 14 | } 15 | } 16 | 17 | internal init(actionDispatcher: ActionDispatcher) { 18 | self.actionDispatcher = actionDispatcher 19 | } 20 | 21 | public func receive(subscription: Subscription) { 22 | self.subscription = subscription 23 | subscription.request(.max(1)) 24 | } 25 | 26 | public func receive(_ input: Action) -> Subscribers.Demand { 27 | actionDispatcher(input) 28 | return .max(1) 29 | } 30 | 31 | public func receive(completion: Subscribers.Completion) { 32 | subscription = nil 33 | } 34 | 35 | public func cancel() { 36 | subscription?.cancel() 37 | subscription = nil 38 | } 39 | } 40 | 41 | extension Publisher where Output == Action, Failure == Never { 42 | 43 | /// Subscribe to a publisher of actions, and send the results to an action dispatcher. 44 | /// 45 | /// - Parameter actionDispatcher: The ActionDispatcher 46 | /// - Returns: A cancellable to unsubscribe. 47 | public func send(to actionDispatcher: ActionDispatcher) -> AnyCancellable { 48 | let subscriber = ActionSubscriber(actionDispatcher: actionDispatcher) 49 | 50 | self.subscribe(subscriber) 51 | return AnyCancellable { subscriber.cancel() } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Action/CompositeAction.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// Combines multiple actions into a chained, composite action. It guarantees the dispatch order of each action. 5 | public struct CompositeAction: RunnableAction { 6 | 7 | @usableFromInline 8 | internal var actions: [Action] = [] 9 | 10 | /// Create a composite action. 11 | /// 12 | /// - Parameter actions: An array of actions to chain. 13 | @usableFromInline internal init(_ actions: [Action] = []) { 14 | self.actions = actions 15 | } 16 | 17 | public func run(store: StoreProxy) -> AnyPublisher { 18 | actions 19 | .publisher 20 | .flatMap(maxPublishers: .max(1)) { action in 21 | self.run(action: action, forStore: store) 22 | } 23 | .eraseToAnyPublisher() 24 | } 25 | 26 | private func run(action: Action, forStore store: StoreProxy) -> AnyPublisher { 27 | if let action = action as? RunnableAction { 28 | return action.run(store: store) 29 | } 30 | return Just(action).eraseToAnyPublisher() 31 | } 32 | } 33 | 34 | /// Chain two actions together as a composite type. 35 | /// 36 | /// - Parameters: 37 | /// - lhs: The first action. 38 | /// - rhs: The next action. 39 | /// - Returns: A composite action. 40 | @inlinable public func + (lhs: Action, rhs: Action) -> CompositeAction { 41 | if var lhs = lhs as? CompositeAction { 42 | lhs.actions.append(rhs) 43 | return lhs 44 | } 45 | return CompositeAction([lhs, rhs]) 46 | } 47 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Action/RunnableAction.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// An action that performs external logic outside of a reducer. 5 | public protocol RunnableAction: Action { 6 | 7 | /// When the action is dispatched to a store, this method will be called to handle 8 | /// any logic by the action. 9 | /// 10 | /// - Parameter store: The store that the action has been dispatched to. 11 | /// - Returns: A cancellable object. 12 | func run(store: StoreProxy) -> AnyPublisher 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Middleware/CompositeMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Use the '+' operator to combine two or more middleware together. 4 | public struct CompositeMiddleware: Middleware where A: Middleware, B: Middleware, A.State == State, B.State == State { 5 | @usableFromInline internal var previousMiddleware: A 6 | @usableFromInline internal var nextMiddleware: B 7 | 8 | @usableFromInline internal init(previousMiddleware: A, nextMiddleware: B) { 9 | self.previousMiddleware = previousMiddleware 10 | self.nextMiddleware = nextMiddleware 11 | } 12 | 13 | /// Unimplemented. It simply calls `store.next(_:)`. 14 | @inlinable public func run(store: StoreProxy, action: Action) -> Action? { 15 | guard let action = previousMiddleware.run(store: store, action: action) else { 16 | return nil 17 | } 18 | return nextMiddleware.run(store: store, action: action) 19 | } 20 | } 21 | 22 | /// Compose two middleware together. 23 | /// 24 | /// - Parameters: 25 | /// - previousMiddleware: The middleware to be called first. 26 | /// - nextMiddleware: The next middleware to call. 27 | /// - Returns: The combined middleware. 28 | @inlinable public func + (previousMiddleware: M1, _ nextMiddleware: M2) -> CompositeMiddleware 29 | where M1: Middleware, M2: Middleware, M1.State == M2.State { 30 | CompositeMiddleware(previousMiddleware: previousMiddleware, nextMiddleware: nextMiddleware) 31 | } 32 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Middleware/HandleActionMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// A simple middleware to perform any handling on a dispatched action. 5 | public final class HandleActionMiddleware: Middleware { 6 | @usableFromInline internal var perform: (StoreProxy, Action) -> Action? 7 | 8 | /// - Parameter body: The block to call when an action is dispatched. 9 | @inlinable public init(perform: @escaping (StoreProxy, Action) -> Action?) { 10 | self.perform = perform 11 | } 12 | 13 | @inlinable public func run(store: StoreProxy, action: Action) -> Action? { 14 | perform(store, action) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Middleware/Middleware.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// Extends the store functionality by providing a middle layer between dispatched actions and the store's reducer. 5 | /// 6 | /// Before an action is given to a reducer, middleware have an opportunity to handle it 7 | /// themselves. They may dispatch their own actions, transform the current action, or 8 | /// block it entirely. 9 | /// 10 | /// Middleware can also be used to set up external hooks from services. 11 | public protocol Middleware { 12 | associatedtype State 13 | 14 | /// Perform any middleware actions within this function. 15 | /// 16 | /// - Parameters: 17 | /// - store: The store object. Use `store.next` when the middleware is complete. 18 | /// - action: The latest dispatched action to process. 19 | /// - Returns: An optional action to pass to the next middleware. 20 | func run(store: StoreProxy, action: Action) -> Action? 21 | 22 | /// Compiles the middleware into a SendAction closure. 23 | /// 24 | /// - Parameter store: A reference to the store used by the middleware. 25 | /// - Returns: The SendAction that performs the middleware. 26 | func compile(store: StoreProxy) -> SendAction 27 | } 28 | 29 | extension Middleware { 30 | 31 | /// Apply the middleware to a store proxy. 32 | /// 33 | /// - Parameter store: The store proxy. 34 | /// - Returns: A SendAction function that performs the middleware for the provided store proxy. 35 | @inlinable public func callAsFunction(store: StoreProxy) -> SendAction { 36 | self.compile(store: store) 37 | } 38 | 39 | @inlinable public func compile(store: StoreProxy) -> SendAction { 40 | { action in _ = self.run(store: store, action: action) } 41 | } 42 | } 43 | 44 | internal final class NoopMiddleware: Middleware { 45 | 46 | @inlinable func run(store: StoreProxy, action: Action) -> Action? { 47 | action 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Middleware/ReducerMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // Reduces the state of a store with the provided action to produce a new state. 4 | internal final class ReducerMiddleware: Middleware where RootReducer: Reducer, RootReducer.State == State { 5 | let reducer: CompositeReducer, RootReducer> 6 | let receivedState: (State) -> Void 7 | 8 | init(reducer: RootReducer, receivedState: @escaping (State) -> Void) { 9 | self.reducer = StoreReducer() + reducer 10 | self.receivedState = receivedState 11 | } 12 | 13 | @inlinable func run(store: StoreProxy, action: Action) -> Action? { 14 | receivedState(reducer(state: store.state, action: action)) 15 | return nil 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Reducer/CompositeReducer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Use the '+' operator to combine two or more reducers together. 4 | public final class CompositeReducer: Reducer where A: Reducer, B: Reducer, A.State == State, B.State == State { 5 | @usableFromInline internal var previousReducer: A 6 | @usableFromInline internal var nextReducer: B 7 | 8 | @usableFromInline internal init(previousReducer: A, nextReducer: B) { 9 | self.previousReducer = previousReducer 10 | self.nextReducer = nextReducer 11 | } 12 | 13 | @inlinable public func reduceAny(state: State, action: Action) -> State { 14 | nextReducer(state: previousReducer(state: state, action: action), action: action) 15 | } 16 | } 17 | 18 | /// Compose two reducers together. 19 | /// 20 | /// - Parameters: 21 | /// - previousReducer: The first reducer to be called. 22 | /// - nextReducer: The second reducer to be called. 23 | /// - Returns: A combined reducer. 24 | @inlinable public func + (previousReducer: R1, _ nextReducer: R2) -> CompositeReducer 25 | where R1: Reducer, R2: Reducer, R1.State == R2.State { 26 | CompositeReducer(previousReducer: previousReducer, nextReducer: nextReducer) 27 | } 28 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Reducer/Reducer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Performs an action on a given state and returns a whole new version. 4 | /// 5 | /// A store is given a single root `Reducer`. As it's sent actions, it runs the reducer to 6 | /// update the application's state. 7 | public protocol Reducer { 8 | 9 | /// The type of state that the `Reducer` is able to mutate. 10 | associatedtype State 11 | 12 | /// The supported actions of a reducer. 13 | associatedtype ReducerAction 14 | 15 | /// Operates on the state with the reducer's own actions, returning a fresh new copy of the state. 16 | /// 17 | /// - Parameters 18 | /// - state: The state to reduce. 19 | /// - action: An action that the reducer is expected to perform on the state. 20 | /// - Returns: A new immutable state. 21 | func reduce(state: State, action: ReducerAction) -> State 22 | 23 | /// Send any kind of action to a reducer. The recuder will determine what it can do with 24 | /// the action. 25 | /// 26 | /// - Parameters 27 | /// - state: The state to reduce 28 | /// - action: Any kind of action. 29 | /// - Returns: A new immutable state 30 | func reduceAny(state: State, action: Action) -> State 31 | } 32 | 33 | extension Reducer { 34 | 35 | @inlinable public func callAsFunction(state: State, action: Action) -> State { 36 | reduceAny(state: state, action: action) 37 | } 38 | 39 | /// Default implementation. Returns the state without modifying it. 40 | /// 41 | /// - Parameters 42 | /// - state: The state to reduce. 43 | /// - action: An unknown action that a subreducer may support. 44 | /// - Returns: A new immutable state. 45 | @inlinable public func reduce(state: State, action: EmptyAction) -> State { 46 | state 47 | } 48 | 49 | /// Send any kind of action to a reducer. The recuder will determine what it can do with 50 | /// the action. 51 | /// 52 | /// - Parameters 53 | /// - state: The state to reduce 54 | /// - action: Any kind of action. 55 | /// - Returns: A new immutable state 56 | @inlinable public func reduceAny(state: State, action: Action) -> State { 57 | guard let reducerAction = action as? ReducerAction else { 58 | return state 59 | } 60 | return reduce(state: state, action: reducerAction) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/SwiftDux/State/IdentifiableState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A type of state that can be identified for tracking purposes. 4 | /// 5 | /// This is typically used for entities stored in your state that might be accessed by id 6 | /// or displayed in a `List` view. 7 | @available(*, deprecated, message: "Use Identifiable instead.") 8 | public protocol IdentifiableState: StateType, Identifiable where ID: Codable {} 9 | 10 | @available(*, deprecated, message: "Use Identifiable instead.") 11 | extension IdentifiableState { 12 | 13 | /// The hash value of the state based on the id. 14 | /// 15 | /// - Parameter hasher: The hasher to apply the hash into. 16 | @inlinable public var hashValue: Int { 17 | id.hashValue 18 | } 19 | 20 | /// Applies the hash of the id to the hasher. 21 | /// 22 | /// - Parameter hasher: The hasher to apply the id's hash into. 23 | @inlinable public func hash(into hasher: inout Hasher) { 24 | id.hash(into: &hasher) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/SwiftDux/State/OrderedState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | fileprivate struct IgnoredDecodedState: Codable {} 4 | 5 | /// Storage for the ordered state to decrease the copying of the internal data structures. 6 | @usableFromInline 7 | internal class OrderedStateStorage where Substate: Identifiable { 8 | enum CodingKeys: String, CodingKey { 9 | case orderOfIds 10 | case values 11 | } 12 | 13 | /// The id type used by the substates. 14 | public typealias ID = Substate.ID 15 | 16 | /// Holds the oredering knowledge of the values by their key. 17 | public var orderOfIds: [ID] 18 | 19 | /// Holds the actual value referenced by its key. 20 | public var values: [ID: Substate] 21 | 22 | /// For the usage of ordered enumerations, this property caches a reverse lookup table from key to ordered position. 23 | public var cachedIdsByOrder: [ID: Int]? 24 | 25 | /// Sets the initial values and their ordered positions. 26 | /// 27 | /// This class assumes that the data in both `orderOfIds` and `values` are perfectly synced. 28 | /// - Parameters 29 | /// - orderOfIds: The ids of each substate in a specific order. 30 | /// - values: A lookup table of substates by their ids. 31 | @inlinable init(orderOfIds: [ID], values: [ID: Substate]) { 32 | self.orderOfIds = orderOfIds 33 | self.values = values 34 | self.cachedIdsByOrder = nil 35 | } 36 | 37 | /// Returns the ordered index position of a key. 38 | /// 39 | /// This method assumes it will be called multiple times in succession, so it internally caches 40 | /// the indexes by their keys in a reverse lookup table. 41 | /// 42 | /// - Parameter id: The key to look up its ordered index position. 43 | /// - Returns: The ordered index that corrosponds to an id. 44 | @inlinable func index(ofId id: ID) -> Int { 45 | if cachedIdsByOrder == nil { 46 | self.cachedIdsByOrder = [ID: Int]( 47 | uniqueKeysWithValues: orderOfIds.enumerated().map { (index, id) in (id, index) } 48 | ) 49 | } 50 | return self.cachedIdsByOrder![id]! 51 | } 52 | 53 | /// Invalidates the caches. This should be called when it's assumed that the order of keys may change. 54 | @inlinable func invalidateCache() { 55 | self.cachedIdsByOrder = nil 56 | } 57 | } 58 | 59 | extension OrderedStateStorage: Equatable where Substate: Equatable { 60 | 61 | @inlinable static func == (lhs: OrderedStateStorage, rhs: OrderedStateStorage) -> Bool { 62 | lhs.orderOfIds == rhs.orderOfIds && lhs.values == rhs.values 63 | } 64 | } 65 | 66 | /// A container state that holds an ordered collection of substates. 67 | /// 68 | /// It's a common requirement to store a collection of substates. For example, a list of entities retrieved from a service. 69 | /// For an optimal solution, you typically require a lookup table of entity states by their ids. However, you also need an ordered array 70 | /// to display those entities in a list to the user. You end up managing both a dictionary of entities and an ordered array of their ids. 71 | /// This struct manages that responsibility for you. It also provides conveniences for direct use by SwiftUI `List` views. 72 | /// 73 | /// ``` 74 | /// var todos: OrderedState = ... 75 | /// 76 | /// // When a user adds a new todo: 77 | /// todos.append(todo) 78 | /// 79 | /// // When a user deletes multiple todos 80 | /// todos.delete(at: indexSet) 81 | /// 82 | /// // Using the OrderedState with a list view. 83 | /// var body: some View { 84 | /// List { 85 | /// ForEach(todos) { 86 | /// } 87 | /// .onDelete { todos.delete(at: $0 } 88 | /// .onMove { todos.move(from: $0, to: $1 } 89 | /// } 90 | /// } 91 | /// 92 | /// ``` 93 | public struct OrderedState where Substate: Identifiable { 94 | 95 | public typealias Id = Substate.ID 96 | public typealias Index = Int 97 | 98 | @usableFromInline 99 | internal var storage: OrderedStateStorage 100 | 101 | /// The substates as an ordered array 102 | @inlinable public var values: [Substate] { 103 | storage.orderOfIds.map { storage.values[$0]! } 104 | } 105 | 106 | /// The number of substates 107 | @inlinable public var count: Int { 108 | storage.orderOfIds.count 109 | } 110 | 111 | /// Used for internal copy operations. 112 | /// 113 | /// - Parameters 114 | /// - orderOfIds: The ids of each substate in a specific order. 115 | /// - values: A lookup table of substates by their ids. 116 | @inlinable internal init(orderOfIds: [Id], values: [Id: Substate]) { 117 | self.storage = OrderedStateStorage( 118 | orderOfIds: orderOfIds, 119 | values: values 120 | ) 121 | } 122 | 123 | /// Create a new `OrderedState` with an ordered array of identifiable substates. 124 | /// 125 | /// - Parameter values: An array of substates. The position of each substate will be used as the initial order. 126 | @inlinable public init(_ values: [Substate]) { 127 | var valueByIndex = [Id: Substate](minimumCapacity: values.count) 128 | let orderOfIds: [Id] = values.map { 129 | valueByIndex[$0.id] = $0 130 | return $0.id 131 | } 132 | 133 | self.init(orderOfIds: orderOfIds, values: valueByIndex) 134 | } 135 | 136 | /// Create a new `OrderedState` with a variadic number of substates. 137 | /// - Parameter value: A variadic list of substates. The position of each substate will be used as the initial order. 138 | @inlinable public init(_ value: Substate...) { 139 | self.init(value) 140 | } 141 | 142 | /// Used internally to copy the storage for mutating operations. 143 | /// 144 | /// It's designed not to copy if it's singularily owned by a single copy of the `OrderedState` struct. 145 | /// This method is expected to be used only by mutating methods, so it also invalidates any caching 146 | /// inside the storage object. 147 | /// - Returns: The original storage if it is referenced only once, or a new copy. 148 | @inlinable internal mutating func copyStorageIfNeeded() -> OrderedStateStorage { 149 | guard isKnownUniquelyReferenced(&storage) else { 150 | return OrderedStateStorage(orderOfIds: storage.orderOfIds, values: storage.values) 151 | } 152 | 153 | storage.invalidateCache() 154 | return storage 155 | } 156 | 157 | /// Retrieves a value by its id. 158 | /// 159 | /// The subscript API should be used in most cases. This method is 160 | /// provided in case the Substate's id type is also an int. 161 | /// - Parameter id: The id of the substate. 162 | /// - Returns: The substate if it exists. 163 | @inlinable public func value(forId id: Id) -> Substate? { 164 | storage.values[id] 165 | } 166 | 167 | /// Append a new substate to the end of the `OrderedState`. 168 | /// 169 | /// - Parameter value: A new substate to append to the end of the list. 170 | @inlinable public mutating func append(_ value: Substate) { 171 | self.remove(forId: value.id) // Remove if it already exists. 172 | 173 | let copy = copyStorageIfNeeded() 174 | 175 | copy.orderOfIds.append(value.id) 176 | copy.values[value.id] = value 177 | self.storage = copy 178 | } 179 | 180 | /// Prepend a new substate to the beginning of the `OrderedState`. 181 | /// 182 | /// - Parameter value: A new substate to append to the beginning of the list. 183 | @inlinable public mutating func prepend(_ value: Substate) { 184 | self.insert(value, at: 0) 185 | } 186 | 187 | /// Inserts a new substate at the given index `OrderedState`. 188 | /// 189 | /// - Parameters 190 | /// - value: A new substate to insert at a specific position in the list 191 | /// - index: The index of the inserted substate. This will adjust the overall order of the list. 192 | @inlinable public mutating func insert(_ value: Substate, at index: Int) { 193 | if let _ = storage.values[value.id], let currentIndex = storage.orderOfIds.firstIndex(of: value.id) { 194 | self.move(from: IndexSet(integer: currentIndex), to: index) 195 | let copy = copyStorageIfNeeded() 196 | copy.values[value.id] = value 197 | self.storage = copy 198 | } else { 199 | let copy = copyStorageIfNeeded() 200 | copy.orderOfIds.insert(value.id, at: index) 201 | copy.values[value.id] = value 202 | self.storage = copy 203 | } 204 | } 205 | 206 | /// Inserts a collection of substates at the given index `OrderedState`. 207 | /// 208 | /// - Parameters 209 | /// - values: The new substates to insert. This must be an ordered collection for defined behavior. 210 | /// - index: The index of the inserted substates. This will adjust the overall order of the list. 211 | @inlinable public mutating func insert(contentsOf values: C, at index: Int) where C: Collection, C.Element == Substate { 212 | let copy = copyStorageIfNeeded() 213 | let ids = values.map { value -> Id in 214 | copy.values[value.id] = value 215 | return value.id 216 | } 217 | 218 | copy.orderOfIds.insert(contentsOf: ids, at: index) 219 | self.storage = copy 220 | } 221 | 222 | /// Removes a substate for the given id. 223 | /// 224 | /// - Parameter id: The id of the substate to remove. This will adjust the order of items. 225 | @inlinable public mutating func remove(forId id: Id) { 226 | guard storage.values[id] != nil else { return } 227 | let copy = copyStorageIfNeeded() 228 | 229 | if copy.values.removeValue(forKey: id) != nil { 230 | copy.orderOfIds.removeAll { $0 == id } 231 | } 232 | 233 | self.storage = copy 234 | } 235 | 236 | /// Removes a substate at a given index. 237 | /// 238 | /// - Parameter index: The index of the substate to remove. This will adjust the order of items. 239 | @inlinable public mutating func remove(at index: Int) { 240 | let copy = copyStorageIfNeeded() 241 | 242 | copy.values.removeValue(forKey: copy.orderOfIds[index]) 243 | copy.orderOfIds.remove(at: index) 244 | self.storage = copy 245 | } 246 | 247 | /// Removes substates at the provided indexes. 248 | /// 249 | /// - Parameter indexSet: Removes all items in the provided indexSet. This will adjust the order of items. 250 | @inlinable public mutating func remove(at indexSet: IndexSet) { 251 | let copy = copyStorageIfNeeded() 252 | 253 | indexSet.forEach { copy.values.removeValue(forKey: copy.orderOfIds[$0]) } 254 | copy.orderOfIds.remove(at: indexSet) 255 | self.storage = copy 256 | } 257 | 258 | /// Moves a set of substates at the specified indexes to a new index position. 259 | /// 260 | /// - Parameters 261 | /// - indexSet: A set of indexes to move to a new location. The order of indexes will be used as part of the new order of the moved items. 262 | /// - index: The new position for the moved items. 263 | @inlinable public mutating func move(from indexSet: IndexSet, to index: Int) { 264 | guard !indexSet.contains(where: { $0 == index }) else { return } 265 | let copy = copyStorageIfNeeded() 266 | let index = Swift.max(Swift.min(index, copy.orderOfIds.count), 0) 267 | let ids = Array(indexSet.map { copy.orderOfIds[$0] }) 268 | let offset = Swift.max(indexSet.reduce(0) { (result, i) in i < index ? result + 1 : result }, 0) 269 | 270 | copy.orderOfIds.remove(at: indexSet) 271 | copy.orderOfIds.insert(contentsOf: ids, at: index - offset) 272 | self.storage = copy 273 | } 274 | 275 | /// Resorts the order of substates with the given sort operation. 276 | /// 277 | /// - Parameter areInIncreasingOrder: Orders the items by indicating whether not the second item is bigger than the first item. 278 | @inlinable public mutating func sort(by areInIncreasingOrder: (Substate, Substate) -> Bool) { 279 | let copy = copyStorageIfNeeded() 280 | 281 | copy.orderOfIds.sort { areInIncreasingOrder(copy.values[$0]!, copy.values[$1]!) } 282 | self.storage = copy 283 | } 284 | 285 | /// Returns an `OrderedState` with the new sort order. 286 | /// 287 | /// - Parameter areInIncreasingOrder: Orders the items by indicating whether not the second item is bigger than the first item. 288 | /// - Returns: A new `OrderedState` with the provided sort operation. 289 | @inlinable public func sorted(by areInIncreasingOrder: (Substate, Substate) -> Bool) -> Self { 290 | OrderedState( 291 | orderOfIds: storage.orderOfIds.sorted { areInIncreasingOrder(storage.values[$0]!, storage.values[$1]!) }, 292 | values: storage.values 293 | ) 294 | } 295 | 296 | /// Filters the substates using a predicate. 297 | /// 298 | /// - Parameter isIncluded: Indicate the state should be included in the returned array. 299 | /// - Returns: an array of substates filtered by the provided operation. 300 | @inlinable public func filter(_ isIncluded: (Substate) -> Bool) -> [Substate] { 301 | storage.orderOfIds.compactMap { id -> Substate? in 302 | let value = storage.values[id]! 303 | return isIncluded(storage.values[id]!) ? value : nil 304 | } 305 | } 306 | } 307 | 308 | extension OrderedState: MutableCollection { 309 | 310 | /// The starting index of the collection. 311 | @inlinable public var startIndex: Int { 312 | storage.orderOfIds.startIndex 313 | } 314 | 315 | /// The last index of the collection. 316 | @inlinable public var endIndex: Int { 317 | storage.orderOfIds.endIndex 318 | } 319 | 320 | /// Subscript based on the index of the substate. 321 | /// 322 | /// - Parameter position: The index of the substate. 323 | /// - Returns: The substate 324 | @inlinable public subscript(position: Int) -> Substate { 325 | get { 326 | storage.values[storage.orderOfIds[position]]! 327 | } 328 | set(newValue) { 329 | self.insert(newValue, at: position) 330 | } 331 | } 332 | 333 | /// Subscript based on the id of the substate. 334 | /// 335 | /// - Parameter position: The id of the substate. 336 | /// - Returns: The substate 337 | @inlinable public subscript(position: Id) -> Substate? { 338 | get { 339 | value(forId: position) 340 | } 341 | set(newValue) { 342 | guard let newValue = newValue else { return } 343 | 344 | if storage.values[position] != nil { 345 | let copy = copyStorageIfNeeded() 346 | 347 | copy.values[position] = newValue 348 | self.storage = copy 349 | } else { 350 | self.append(newValue) 351 | } 352 | } 353 | } 354 | 355 | /// Create an ordered iterator of the substates. 356 | /// 357 | /// - Returns: The ordered iterator. 358 | @inlinable public __consuming func makeIterator() -> IndexingIterator<[Substate]> { 359 | return self.values.makeIterator() 360 | } 361 | 362 | /// Get the index of a substate after a sibling one. 363 | /// 364 | /// - Parameter i: The index of the substate directly before the target one. 365 | /// - Returns: The next index 366 | @inlinable public func index(after i: Int) -> Int { 367 | return storage.orderOfIds.index(after: i) 368 | } 369 | 370 | /// Get the id of a substate directly after a sibling one. 371 | /// 372 | /// - Parameter i: The id of the substate directly before the target one. 373 | /// - Returns: The next id 374 | @inlinable public func index(after i: Id) -> Id { 375 | let index = storage.index(ofId: i) 376 | return storage.orderOfIds[index + 1] 377 | } 378 | } 379 | 380 | extension OrderedState: RandomAccessCollection {} 381 | 382 | extension RangeReplaceableCollection where Self: MutableCollection, Index == Int { 383 | 384 | /// From user vadian at [stackoverflows](https://stackoverflow.com/a/50835467) 385 | /// Walks the length of the collection using `swapAt` to move all deleted items to the end. Then it performs 386 | /// a single remove operation. 387 | 388 | /// Removes substates at the provided indexes. 389 | /// - Parameter indexes: Removes all items in the provided indexSet. 390 | @inlinable public mutating func remove(at indexes: IndexSet) { 391 | guard var i = indexes.first, i < count else { return } 392 | var j = index(after: i) 393 | var k = indexes.integerGreaterThan(i) ?? endIndex 394 | 395 | while j != endIndex { 396 | if k != j { 397 | swapAt(i, j) 398 | formIndex(after: &i) 399 | } else { 400 | k = indexes.integerGreaterThan(k) ?? endIndex 401 | } 402 | formIndex(after: &j) 403 | } 404 | 405 | removeSubrange(i...) 406 | } 407 | } 408 | 409 | extension OrderedState: Equatable where Substate: Equatable { 410 | 411 | @inlinable public static func == (lhs: OrderedState, rhs: OrderedState) -> Bool { 412 | lhs.storage == rhs.storage 413 | } 414 | } 415 | 416 | extension OrderedState: Decodable where Substate: Decodable { 417 | 418 | ///Decodes the `OrderState<_>` from an unkeyed container. 419 | /// 420 | /// This allows the `OrderedState<_>` to be decoded from a simple array. 421 | /// 422 | /// - Parameter decoder: The decoder. 423 | /// - Throws: This function throws an error if the OrderedState could not be decoded. 424 | public init(from decoder: Decoder) throws { 425 | var container = try decoder.unkeyedContainer() 426 | var values = [Substate]() 427 | 428 | while !container.isAtEnd { 429 | do { 430 | values.append(try container.decode(Substate.self)) 431 | } catch { 432 | _ = try container.decode(IgnoredDecodedState.self) 433 | } 434 | } 435 | 436 | self.init(values) 437 | } 438 | 439 | } 440 | 441 | extension OrderedState: Encodable where Substate: Encodable { 442 | 443 | /// Encodes the `OrderState<_>` as an unkeyed container of values. 444 | /// 445 | /// This allows the `OrderedState<_>` to be encoded as simple array. 446 | /// 447 | /// - Parameter encoder: The encoder. 448 | /// - Throws: This function throws an error if the OrderedState could not be encoded. 449 | @inlinable public func encode(to encoder: Encoder) throws { 450 | var container = encoder.unkeyedContainer() 451 | try container.encode(contentsOf: values) 452 | } 453 | 454 | } 455 | -------------------------------------------------------------------------------- /Sources/SwiftDux/State/StateType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A convienence type for the application state to adhere to. 4 | @available(*, deprecated) 5 | public typealias StateType = Codable & Equatable 6 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Store/StateStorable.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// Represents a storable container for a state object. 5 | /// 6 | /// Extend this protocol to implement new methods for the Store<_> and StoreProxy<_> types. 7 | public protocol StateStorable { 8 | /// The type of the stored state object. 9 | associatedtype State 10 | 11 | /// The latest state of the store. 12 | var state: State { get } 13 | 14 | /// Emits after the state has been changed. 15 | var didChange: StorePublisher { get } 16 | } 17 | 18 | extension StateStorable { 19 | 20 | /// Publishes the state as it changes with a mapping function. 21 | /// 22 | /// - Parameter mapState: Maps the state to a more relevant props object. 23 | /// - Returns: A new publisher that emits non-duplicate updates. 24 | @inlinable public func publish(_ mapState: @escaping (State) -> Props) 25 | -> Publishers.RemoveDuplicates>, Props>> where Props: Equatable 26 | { 27 | didChange 28 | .merge(with: Just(())) 29 | .map { mapState(state) } 30 | .removeDuplicates() 31 | } 32 | } 33 | 34 | extension StateStorable where State: Equatable { 35 | 36 | /// Publishes the state as it changes. 37 | /// 38 | /// - Returns: A new publisher that emits non-duplicate updates. 39 | @inlinable public func publish() -> Publishers.RemoveDuplicates>, State>> { 40 | publish { $0 } 41 | } 42 | } 43 | 44 | extension StateStorable where Self: ActionDispatcher { 45 | 46 | /// Create a proxy of the `StateStorable` for a given type or protocol. 47 | /// 48 | /// - Parameter dispatcher: An optional dispatcher for the proxy. 49 | /// - Returns: A proxy object if the state type matches, otherwise nil. 50 | @inlinable public func proxy(dispatcher: ActionDispatcher? = nil) -> StoreProxy { 51 | StoreProxy( 52 | getState: { state }, 53 | didChange: didChange, 54 | dispatcher: dispatcher ?? self 55 | ) 56 | } 57 | 58 | /// Create a proxy of the `StateStorable` for a given type or protocol. 59 | /// 60 | /// - Parameters: 61 | /// - stateType: The type of state for the proxy. This must be a type that the store adheres to. 62 | /// - dispatcher: An optional dispatcher for the proxy. 63 | /// - Returns: A proxy object if the state type matches, otherwise nil. 64 | @inlinable public func proxy(for stateType: T.Type, dispatcher: ActionDispatcher? = nil) -> StoreProxy? { 65 | guard state is T else { return nil } 66 | return StoreProxy( 67 | getState: { state as! T }, 68 | didChange: didChange, 69 | dispatcher: dispatcher ?? self 70 | ) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Store/Store.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// Stores and mutates the state of an application. 5 | public final class Store: StateStorable { 6 | 7 | /// The current state of the store. 8 | public private(set) var state: State { 9 | didSet { didChange.send() } 10 | } 11 | 12 | /// Publishes when the state has changed. 13 | public let didChange = StorePublisher() 14 | 15 | @usableFromInline 16 | internal var reduce: SendAction = { _ in } 17 | 18 | /// Initiates a new store for the given state and reducer. 19 | /// 20 | /// - Parameters 21 | /// - state: The initial state of the store. 22 | /// - reducer: A reducer that mutates the state as actions are dispatched to it. 23 | /// - middleware: A middleware plugin. 24 | public init(state: State, reducer: R, middleware: M) where R: Reducer, R.State == State, M: Middleware, M.State == State { 25 | self.state = state 26 | self.reduce = compile(middleware: middleware + ReducerMiddleware(reducer: reducer) { [weak self] in self?.state = $0 }) 27 | send(StoreAction.prepare) 28 | } 29 | 30 | /// Initiates a new store for the given state and reducer. 31 | /// 32 | /// - Parameters 33 | /// - state: The initial state of the store. 34 | /// - reducer: A reducer that mutates the state as actions are dispatched to it. 35 | public convenience init(state: State, reducer: R) where R: Reducer, R.State == State { 36 | self.init(state: state, reducer: reducer, middleware: NoopMiddleware()) 37 | } 38 | 39 | private func compile(middleware: M) -> SendAction where M: Middleware, M.State == State { 40 | middleware( 41 | store: StoreProxy( 42 | getState: { [unowned self] in self.state }, 43 | didChange: didChange, 44 | dispatcher: ActionDispatcherProxy( 45 | send: { [unowned self] in self.send($0) }, 46 | sendAsCancellable: { [unowned self] in self.sendAsCancellable($0) } 47 | ) 48 | ) 49 | ) 50 | } 51 | } 52 | 53 | extension Store: ActionDispatcher { 54 | 55 | /// Sends an action to mutate the state. 56 | /// 57 | /// - Parameter action: The action to perform. 58 | @inlinable public func send(_ action: Action) { 59 | if let action = action as? RunnableAction { 60 | reduceRunnableAction(action) 61 | } else { 62 | reduce(action) 63 | } 64 | } 65 | 66 | /// Sends an action to mutate the state. 67 | /// 68 | /// - Parameter action: The action to perform. 69 | /// - Returns: A cancellable object. 70 | @inlinable public func sendAsCancellable(_ action: Action) -> Cancellable { 71 | if let action = action as? RunnableAction { 72 | return action.run(store: self.proxy()).send(to: self) 73 | } 74 | return Just(action).send(to: self) 75 | } 76 | 77 | /// Reduces a runnable action. 78 | /// 79 | /// - Parameter action: The action to perform. 80 | @usableFromInline internal func reduceRunnableAction(_ action: RunnableAction) { 81 | var cancellable: AnyCancellable? = nil 82 | 83 | cancellable = action.run(store: self.proxy()) 84 | .handleEvents(receiveCompletion: { _ in 85 | cancellable?.cancel() 86 | cancellable = nil 87 | }) 88 | .send(to: self) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Store/StoreProxy.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// Creates a proxy of the store object for use by middleware. 5 | /// 6 | /// Middleware may use the store proxy to retreive the current state, send actions, 7 | /// continue to the next middleware, or subscribe to store changes. With the proxy, 8 | /// middleware don't have to worry about retaining the store. Instead, the proxy provides 9 | /// a safe API to access a weak reference to it. 10 | public struct StoreProxy: StateStorable, ActionDispatcher { 11 | @usableFromInline internal var getState: () -> State 12 | 13 | /// Emits after the specified action was sent to the store. 14 | public var didChange: StorePublisher 15 | 16 | /// Send an action to the next middleware 17 | @usableFromInline 18 | internal var dispatcher: ActionDispatcher 19 | 20 | /// Retrieves the latest state from the store. 21 | public var state: State { 22 | getState() 23 | } 24 | 25 | @inlinable internal init( 26 | getState: @escaping () -> State, 27 | didChange: StorePublisher, 28 | dispatcher: ActionDispatcher 29 | ) { 30 | self.getState = getState 31 | self.didChange = didChange 32 | self.dispatcher = dispatcher 33 | } 34 | 35 | /// Sends an action to mutate the application state. 36 | /// 37 | /// - Parameter action: The action to send 38 | @inlinable public func send(_ action: Action) { 39 | dispatcher.send(action) 40 | } 41 | 42 | /// Sends an action to mutate the application state. 43 | /// 44 | /// - Parameter action: The action to send. 45 | /// - Returns: A cancellable object. 46 | @inlinable public func sendAsCancellable(_ action: Action) -> Cancellable { 47 | dispatcher.sendAsCancellable(action) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Store/StorePublisher.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// Publishes state changes from the store. 5 | public final class StorePublisher: Publisher { 6 | public typealias Failure = Never 7 | public typealias Output = Void 8 | private let subject = PassthroughSubject() 9 | 10 | public func receive(subscriber: S) where S: Subscriber, S.Failure == Never, S.Input == Void { 11 | subject.receive(subscriber: subscriber) 12 | } 13 | 14 | internal func send() { 15 | subject.send() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SwiftDux/Store/StoreReducer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Actions performed by the store itself. 4 | public enum StoreAction: Action { 5 | 6 | /// Called at the initialization step of the store to allow reducers and middleware an oppertunity 7 | /// to set up configurations. Store actions may be dispatched at this stage, but other middleware 8 | /// and reducers might not be ready yet if they require any preparation themselves. 9 | case prepare 10 | 11 | /// Reset the entire state of the application. 12 | case reset(state: State) 13 | } 14 | 15 | internal final class StoreReducer: Reducer { 16 | 17 | @inlinable public func reduce(state: State, action: StoreAction) -> State { 18 | switch action { 19 | case .reset(let newState): 20 | return newState 21 | default: 22 | return state 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/SwiftDux/UI/ActionBinder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// Binds a state to a setter based action for use by controls that expect a two-way binding value such as TextFields. 5 | /// This is useful for simple actions that are expected to be dispatched many times a second. It should be avoided by any 6 | /// complicated or asynchronous actions. 7 | /// 8 | /// ``` 9 | /// func map(state: AppState, binder: StateBinder) -> Props? { 10 | /// Props( 11 | /// todos: state.todos, 12 | /// orderBy: binder.bind(state.orderBy) { TodoListAction.setOrderBy($0) } 13 | /// ) 14 | /// } 15 | /// ``` 16 | public struct ActionBinder { 17 | @usableFromInline 18 | internal var actionDispatcher: ActionDispatcher 19 | 20 | /// Create a binding between a given state and an action. 21 | /// 22 | /// - Parameters: 23 | /// - state: The state to retrieve. 24 | /// - getAction: Given a new version of the state, it returns an action to dispatch. 25 | /// - Returns: A new Binding object. 26 | @inlinable public func bind(_ state: T, dispatch getAction: @escaping (T) -> Action?) -> ActionBinding where T: Equatable { 27 | ActionBinding( 28 | value: state, 29 | isEqual: { state == $0 }, 30 | set: { 31 | self.dispatch(getAction($0)) 32 | } 33 | ) 34 | } 35 | 36 | /// Create a function binding that dispatches an action. 37 | /// 38 | /// - Parameter getAction: A closure that returns an action to dispatch. 39 | /// - Returns: a function that dispatches the action. 40 | @inlinable public func bind(_ getAction: @escaping () -> Action?) -> ActionBinding<() -> Void> { 41 | .constant { self.dispatch(getAction()) } 42 | } 43 | 44 | /// Create a function binding that dispatches an action. 45 | /// 46 | /// - Parameter getAction: A closure that returns an action to dispatch. 47 | /// - Returns: a function that dispatches the action. 48 | @inlinable public func bind(_ getAction: @escaping (P0) -> Action?) -> ActionBinding<(P0) -> Void> { 49 | .constant { self.dispatch(getAction($0)) } 50 | } 51 | 52 | /// Create a function binding that dispatches an action. 53 | /// 54 | /// - Parameter getAction: A closure that returns an action to dispatch. 55 | /// - Returns: a function that dispatches the action. 56 | @inlinable public func bind(_ getAction: @escaping (P0, P1) -> Action?) -> ActionBinding<(P0, P1) -> Void> { 57 | .constant { self.dispatch(getAction($0, $1)) } 58 | } 59 | 60 | /// Create a function binding that dispatches an action. 61 | /// 62 | /// - Parameter getAction: A closure that returns an action to dispatch. 63 | /// - Returns: a function that dispatches the action. 64 | @inlinable public func bind( 65 | _ getAction: @escaping (P0, P1, P2) -> Action? 66 | ) -> ActionBinding<(P0, P1, P2) -> Void> { 67 | .constant { self.dispatch(getAction($0, $1, $2)) } 68 | } 69 | 70 | /// Create a function binding that dispatches an action. 71 | /// 72 | /// - Parameter getAction: A closure that returns an action to dispatch. 73 | /// - Returns: a function that dispatches the action. 74 | @inlinable public func bind( 75 | _ getAction: @escaping (P0, P1, P2, P3) -> Action? 76 | ) -> ActionBinding<(P0, P1, P2, P3) -> Void> { 77 | .constant { self.dispatch(getAction($0, $1, $2, $3)) } 78 | } 79 | 80 | /// Create a function binding that dispatches an action. 81 | /// 82 | /// - Parameter getAction: A closure that returns an action to dispatch. 83 | /// - Returns: a function that dispatches the action. 84 | @inlinable public func bind( 85 | _ getAction: @escaping (P0, P1, P2, P3, P4) -> Action? 86 | ) -> ActionBinding<(P0, P1, P2, P3, P4) -> Void> { 87 | .constant { self.dispatch(getAction($0, $1, $2, $3, $4)) } 88 | } 89 | 90 | /// Create a function binding that dispatches an action. 91 | /// 92 | /// - Parameter getAction: A closure that returns an action to dispatch. 93 | /// - Returns: a function that dispatches the action. 94 | @inlinable public func bind( 95 | _ getAction: @escaping (P0, P1, P2, P3, P4, P5) -> Action? 96 | ) -> ActionBinding<(P0, P1, P2, P3, P4, P5) -> Void> { 97 | .constant { self.dispatch(getAction($0, $1, $2, $3, $4, $5)) } 98 | } 99 | 100 | @usableFromInline internal func dispatch(_ action: Action?) { 101 | guard let action = action else { return } 102 | actionDispatcher.send(action) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Sources/SwiftDux/UI/ActionBinding.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// Binds a value with an action. Use the `ActionBinder` to create an action binding. 5 | @propertyWrapper 6 | public struct ActionBinding { 7 | 8 | @usableFromInline 9 | internal var isEqual: (Value) -> Bool 10 | 11 | /// Projects to a regular binding when using the '$' prefix. 12 | public var projectedValue: Binding 13 | 14 | /// The current value of the binding. 15 | @inlinable public var wrappedValue: Value { 16 | get { projectedValue.wrappedValue } 17 | set { projectedValue.wrappedValue = newValue } 18 | } 19 | 20 | @inlinable internal init(value: Value, isEqual: @escaping (Value) -> Bool, set: @escaping (Value) -> Void) { 21 | self.isEqual = isEqual 22 | self.projectedValue = Binding(get: { value }, set: set) 23 | } 24 | 25 | @inlinable static internal func constant(value: T) -> ActionBinding { 26 | ActionBinding(value: value, isEqual: { _ in true }, set: { _ in }) 27 | } 28 | 29 | @inlinable static internal func constant(value: T) -> ActionBinding where T: Equatable { 30 | ActionBinding(value: value, isEqual: { value == $0 }, set: { _ in }) 31 | } 32 | 33 | /// Returns a regular binding. 34 | /// - Returns: The binding. 35 | @inlinable public func toBinding() -> Binding { 36 | projectedValue 37 | } 38 | } 39 | 40 | extension ActionBinding: Equatable { 41 | 42 | @inlinable public static func == (lhs: ActionBinding, rhs: ActionBinding) -> Bool { 43 | lhs.isEqual(rhs.wrappedValue) && rhs.isEqual(lhs.wrappedValue) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/SwiftDux/UI/Connectable.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Makes a view "connectable" to the application state. 4 | /// 5 | /// This should not be used directly. Instead, use ConnectableView. This protocol isn't combined into ConnectableView due 6 | /// to a possible bug in Swift that throws an invalid assocated type if Props isn't explicitly typealiased. 7 | public protocol Connectable { 8 | 9 | associatedtype State 10 | associatedtype Props: Equatable 11 | 12 | /// Map a superstate to the state needed by the view using the provided parameter. 13 | /// 14 | /// The method can return nil until the state becomes available. While it is nil, the view 15 | /// will not be rendered. 16 | /// - Parameter state: The superstate provided to the view from a superview. 17 | /// - Returns: The state if possible. 18 | func map(state: State) -> Props? 19 | 20 | /// Map a superstate to the state needed by the view using the provided parameter. 21 | /// 22 | /// The method can return nil until the state becomes available. While it is nil, the view 23 | /// will not be rendered. 24 | /// - Parameters: 25 | /// - state: The superstate provided to the view from a superview. 26 | /// - binder: Helper that creates Binding types beteen the state and a dispatcable action 27 | /// - Returns: The state if possible. 28 | func map(state: State, binder: ActionBinder) -> Props? 29 | } 30 | 31 | extension Connectable { 32 | 33 | /// Default implementation. Returns nil. 34 | @inlinable public func map(state: State) -> Props? { 35 | nil 36 | } 37 | 38 | /// Default implementation. Calls the other map function. 39 | @inlinable public func map(state: State, binder: ActionBinder) -> Props? { 40 | map(state: state) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/SwiftDux/UI/ConnectableView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view that connects to the application state. 4 | public protocol ConnectableView: View, Connectable { 5 | associatedtype Content: View 6 | associatedtype Body = Connector 7 | 8 | /// Return the body of the view using the provided props object. 9 | /// - Parameter props: A mapping of the application to the props used by the view. 10 | /// - Returns: The connected view. 11 | func body(props: Props) -> Content 12 | } 13 | 14 | extension ConnectableView { 15 | 16 | public var body: Connector { 17 | Connector(mapState: map, content: body) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SwiftDux/UI/ConnectableViewModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view modifier that connects to the application state. 4 | public protocol ConnectableViewModifier: ViewModifier, Connectable { 5 | associatedtype InnerBody: View 6 | associatedtype Body = Connector 7 | 8 | /// Return the body of the view modifier using the provided props object. 9 | /// - Parameters: 10 | /// - props: A mapping of the application to the props used by the view. 11 | /// - content: The content of the view modifier. 12 | /// - Returns: The connected view. 13 | func body(props: Props, content: Content) -> InnerBody 14 | } 15 | 16 | extension ConnectableViewModifier { 17 | 18 | public func body(content: Content) -> Connector { 19 | Connector(mapState: map) { props in 20 | body(props: props, content: content) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/SwiftDux/UI/Connector.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | public struct Connector: View where Props: Equatable, Content: View { 5 | @Environment(\.store) private var anyStore 6 | @Environment(\.actionDispatcher) private var dispatch 7 | 8 | private var mapState: (State, ActionBinder) -> Props? 9 | private var content: (Props) -> Content 10 | @SwiftUI.State private var props: Props? 11 | 12 | private var store: StoreProxy? { 13 | if anyStore is NoopAnyStore { 14 | return nil 15 | } else if let store = anyStore.unwrap(as: State.self) { 16 | return store 17 | } 18 | fatalError("Tried mapping the state to a view, but the Store<_> doesn't conform to '\(State.self)'") 19 | } 20 | 21 | public init( 22 | mapState: @escaping (State, ActionBinder) -> Props?, 23 | @ViewBuilder content: @escaping (Props) -> Content 24 | ) { 25 | self.content = content 26 | self.mapState = mapState 27 | } 28 | 29 | public var body: some View { 30 | store.map { store in 31 | Group { 32 | props.map { content($0) } 33 | // SwiftUI sometimes crashes without this line in iOS 14+: 34 | EmptyView() 35 | }.onReceive(store.publish(mapState)) { self.props = $0 } 36 | } 37 | } 38 | 39 | private func mapState(state: State) -> Props? { 40 | mapState(state, ActionBinder(actionDispatcher: dispatch)) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/SwiftDux/UI/Extensions/Environment+ActionDispatcher.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | /// Default value of the actionDispatcher environment value. 5 | internal struct NoopActionDispatcher: ActionDispatcher { 6 | 7 | func send(_ action: Action) { 8 | print("Tried dispatching an action `\(action)` without providing a store object.") 9 | } 10 | 11 | func sendAsCancellable(_ action: Action) -> Cancellable { 12 | print("Tried dispatching an action `\(action)` without providing a store object.") 13 | return AnyCancellable {} 14 | } 15 | } 16 | 17 | internal struct ActionDispatcherKey: EnvironmentKey { 18 | typealias Value = ActionDispatcher 19 | static var defaultValue: Value = NoopActionDispatcher() 20 | } 21 | 22 | extension EnvironmentValues { 23 | 24 | /// Environment value to supply an actionDispatcher. This is used by the MappedDispatch to retrieve 25 | /// an action dispatcher from the environment. 26 | public var actionDispatcher: ActionDispatcher { 27 | get { 28 | self[ActionDispatcherKey.self] 29 | } 30 | set { 31 | self[ActionDispatcherKey.self] = newValue 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/SwiftDux/UI/Extensions/Environment+AnyStore.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | /// A type-erased wrapper of a Store. 5 | public protocol AnyStore: ActionDispatcher { 6 | 7 | /// Unwrap the store for a specific state type. 8 | /// - Parameter type: The type of state expected. 9 | /// - Returns: The unwrapped store if successful. 10 | func unwrap(as type: T.Type) -> StoreProxy? 11 | } 12 | 13 | internal final class AnyStoreWrapper: AnyStore { 14 | let store: Store 15 | 16 | init(store: Store) { 17 | self.store = store 18 | } 19 | 20 | func unwrap(as type: T.Type) -> StoreProxy? { 21 | store.proxy(for: type) 22 | } 23 | 24 | func send(_ action: Action) { 25 | store.send(action) 26 | } 27 | 28 | func sendAsCancellable(_ action: Action) -> Cancellable { 29 | store.sendAsCancellable(action) 30 | } 31 | } 32 | 33 | struct NoopAnyStore: AnyStore { 34 | func unwrap(as type: T.Type) -> StoreProxy? { 35 | return nil 36 | } 37 | 38 | func send(_ action: Action) { 39 | // Do nothing 40 | } 41 | 42 | func sendAsCancellable(_ action: Action) -> Cancellable { 43 | AnyCancellable {} 44 | } 45 | } 46 | 47 | public final class StoreWrapperEnvironmentKey: EnvironmentKey { 48 | public static var defaultValue: AnyStore { 49 | NoopAnyStore() 50 | } 51 | } 52 | 53 | extension EnvironmentValues { 54 | 55 | /// A type-erased wrapper of the Store. 56 | public var store: AnyStore { 57 | get { self[StoreWrapperEnvironmentKey.self] } 58 | set { self[StoreWrapperEnvironmentKey.self] = newValue } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/SwiftDux/UI/MappedDispatch.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import SwiftUI 4 | 5 | /// Injects a function as a property in a view to dispatch actions to the provided store. 6 | /// ``` 7 | /// struct MyView : View { 8 | /// 9 | /// @MappedDispatch() var dispatch 10 | /// 11 | /// func handleClick() { 12 | /// dispatch(AppAction.doSomething()) 13 | /// } 14 | /// 15 | /// } 16 | /// ``` 17 | @available(*, deprecated, message: "Use @Environment(.\\actionDispatcher) instead") 18 | @propertyWrapper 19 | public struct MappedDispatch: DynamicProperty { 20 | @Environment(\.actionDispatcher) private var actionDispatcher: ActionDispatcher 21 | 22 | public var wrappedValue: ActionDispatcher { 23 | actionDispatcher 24 | } 25 | 26 | public init() {} 27 | } 28 | -------------------------------------------------------------------------------- /Sources/SwiftDux/UI/MappedState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// Retrieves a mapping of the application state from the environment and provides it to a property in a SwiftUI view. 5 | /// ``` 6 | /// struct MyView : View { 7 | /// @MappedState var todoList: TodoList 8 | /// } 9 | /// ``` 10 | @available(*, deprecated) 11 | @propertyWrapper 12 | public struct MappedState: DynamicProperty { 13 | @Environment(\.store) private var anyStore 14 | 15 | private var store: StoreProxy? 16 | 17 | public var wrappedValue: State { 18 | guard let store = store else { 19 | fatalError("Tried mapping the state to a view, but the Store<_> doesn't conform to '\(State.self)'") 20 | } 21 | return store.state 22 | } 23 | 24 | public mutating func update() { 25 | self.store = anyStore.unwrap(as: State.self) 26 | } 27 | 28 | public init() {} 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SwiftDux/UI/ViewModifiers/OnActionViewModifier.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | @available(*, deprecated, message: "Use middleware instead.") 5 | public struct OnActionViewModifier: ViewModifier { 6 | @Environment(\.actionDispatcher) private var actionDispatcher 7 | private var perform: ActionModifier? = nil 8 | 9 | @usableFromInline internal init(perform: ActionModifier? = nil) { 10 | self.perform = perform 11 | } 12 | 13 | public func body(content: Content) -> some View { 14 | var nextActionDispatcher = actionDispatcher 15 | 16 | if let perform = perform { 17 | nextActionDispatcher = OnActionDispatcher(actionModifier: perform, nextDispatcher: actionDispatcher) 18 | } 19 | 20 | return content.environment(\.actionDispatcher, nextActionDispatcher) 21 | } 22 | } 23 | 24 | @available(*, deprecated) 25 | extension OnActionViewModifier { 26 | 27 | /// A closure that can return a new action from a previous one. If no action is returned, 28 | /// the original action is not sent. 29 | public typealias ActionModifier = (Action) -> Action? 30 | 31 | private struct OnActionDispatcher: ActionDispatcher { 32 | var actionModifier: ActionModifier 33 | var nextDispatcher: ActionDispatcher 34 | 35 | func send(_ action: Action) { 36 | guard let action = actionModifier(action) else { return } 37 | nextDispatcher.send(action) 38 | } 39 | 40 | func sendAsCancellable(_ action: Action) -> Cancellable { 41 | nextDispatcher.sendAsCancellable(action) 42 | } 43 | } 44 | } 45 | 46 | extension View { 47 | 48 | /// Fires when a child view dispatches an action. 49 | /// 50 | /// - Parameter perform: Calls the closure when an action is dispatched. An optional new action can be returned to change the action. 51 | /// - Returns: The modified view. 52 | @available(*, deprecated, message: "Use middleware instead.") 53 | @inlinable public func onAction(perform: @escaping OnActionViewModifier.ActionModifier) -> some View { 54 | modifier(OnActionViewModifier(perform: perform)) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/SwiftDux/UI/ViewModifiers/OnAppearDispatchViewModifier.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Dispatch 3 | import SwiftUI 4 | 5 | public struct OnAppearDispatchActionViewModifier: ViewModifier { 6 | @Environment(\.actionDispatcher) private var dispatch 7 | @State private var cancellable: Cancellable? = nil 8 | private var action: Action 9 | 10 | @usableFromInline internal init(action: Action) { 11 | self.action = action 12 | } 13 | 14 | public func body(content: Content) -> some View { 15 | content.onAppear { dispatch(action) } 16 | } 17 | } 18 | 19 | public struct OnAppearDispatchActionPlanViewModifier: ViewModifier { 20 | @Environment(\.actionDispatcher) private var dispatch 21 | 22 | private var action: RunnableAction 23 | private var cancelOnDisappear: Bool 24 | 25 | @State private var cancellable: Cancellable? = nil 26 | 27 | @usableFromInline internal init(action: RunnableAction, cancelOnDisappear: Bool) { 28 | self.action = action 29 | self.cancelOnDisappear = cancelOnDisappear 30 | } 31 | 32 | public func body(content: Content) -> some View { 33 | content 34 | .onAppear { 35 | guard cancellable == nil else { return } 36 | self.cancellable = dispatch.sendAsCancellable(action) 37 | } 38 | .onDisappear { 39 | if cancelOnDisappear { 40 | self.cancellable?.cancel() 41 | self.cancellable = nil 42 | } 43 | } 44 | } 45 | } 46 | 47 | extension View { 48 | 49 | /// Sends the provided action when the view appears. 50 | /// 51 | /// - Parameter action: An action to dispatch every time the view appears. 52 | /// - Returns: The modified view. 53 | @inlinable public func onAppear(dispatch action: Action) -> some View { 54 | Group { 55 | if let action = action as? RunnableAction { 56 | modifier(OnAppearDispatchActionPlanViewModifier(action: action, cancelOnDisappear: true)) 57 | } else { 58 | modifier(OnAppearDispatchActionViewModifier(action: action)) 59 | } 60 | } 61 | } 62 | 63 | /// Sends the provided action plan when the view appears. 64 | /// 65 | /// In the follow example an ActionPlan is created that automatically updates a list of todos when the filter property of 66 | /// the TodoList state changes. All the view needs to do is dispatch the action when it appears. 67 | /// ``` 68 | /// // In the TodoListAction file: 69 | /// 70 | /// enum TodoListAction: Action { 71 | /// case setTodos([TodoItem]) 72 | /// case setFilterBy(String) 73 | /// } 74 | /// 75 | /// extension TodoListAction { 76 | /// 77 | /// static func queryTodos(from services: Services) -> Action { 78 | /// ActionPlan { store in 79 | /// store.didChange 80 | /// .filter { $0 is TodoListAction } 81 | /// .map { _ in store.state?.todoList.filterBy ?? "" } 82 | /// .removeDuplicates() 83 | /// .flatMap { filter in 84 | /// services 85 | /// .queryTodos(filter: filter) 86 | /// .catch { _ in Just<[TodoItem]>([]) } 87 | /// .map { todos -> Action in TodoListAction.setTodos(todos) } 88 | /// } 89 | /// } 90 | /// } 91 | /// } 92 | /// 93 | /// // In a SwiftUI View: 94 | /// 95 | /// @Environment(\.services) private var services 96 | /// @MappedState private var todos: [TodoItem] 97 | /// 98 | /// var body: some View { 99 | /// Group { 100 | /// renderTodos(todos: todos) 101 | /// } 102 | /// .onAppear(dispatch: TodoListAction.queryTodos(from: services)) 103 | /// } 104 | /// ``` 105 | /// 106 | /// - Parameters: 107 | /// - action: An action to dispatch every time the view appears. 108 | /// - cancelOnDisappear: It will cancel any subscription from the action when the view disappears. If false, it keeps 109 | /// the subscription alive and reppearances of the view will not re-call the action. 110 | /// - Returns: The modified view. 111 | @inlinable public func onAppear(dispatch action: RunnableAction, cancelOnDisappear: Bool) -> some View { 112 | modifier(OnAppearDispatchActionPlanViewModifier(action: action, cancelOnDisappear: cancelOnDisappear)) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Sources/SwiftDux/UI/ViewModifiers/StoreProviderViewModifier.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | /// A view modifier that injects a store into the environment. 5 | internal struct StoreProviderViewModifier: ViewModifier { 6 | private var store: AnyStore 7 | 8 | init(store: AnyStore) { 9 | self.store = store 10 | } 11 | 12 | public func body(content: Content) -> some View { 13 | content 14 | .environment(\.store, store) 15 | .environment(\.actionDispatcher, store) 16 | } 17 | } 18 | 19 | extension View { 20 | 21 | /// Injects a store into the environment. 22 | /// 23 | /// The store can then be used by the `@EnvironmentObject` 24 | /// property wrapper. This method also enables the use of `View.mapState(updateOn:_:)` to 25 | /// map substates to a view. 26 | /// ``` 27 | /// struct RootView: View { 28 | /// // Passed in from the AppDelegate or SceneDelegate class. 29 | /// var store: Store 30 | /// 31 | /// 32 | /// var body: some View { 33 | /// RootAppNavigation() 34 | /// .provideStore(store) 35 | /// } 36 | /// } 37 | /// ``` 38 | /// - Parameter store: The store object to inject. 39 | /// - Returns: The modified view. 40 | public func provideStore(_ store: Store) -> some View where State: Equatable { 41 | modifier(StoreProviderViewModifier(store: AnyStoreWrapper(store: store))) 42 | } 43 | 44 | public func provideStore(_ store: AnyStore) -> some View { 45 | modifier(StoreProviderViewModifier(store: store)) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/SwiftDuxExtras/Persistence/JSONStatePersistor.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import SwiftDux 4 | 5 | /// Persist the application state as JSON. 6 | public final class JSONStatePersistor: StatePersistor where State: Codable { 7 | 8 | /// The storage location of the JSON data. 9 | public let location: StatePersistentLocation 10 | 11 | private let encoder = JSONEncoder() 12 | private let decoder = JSONDecoder() 13 | 14 | private var subscription: Subscription? { 15 | willSet { 16 | guard let subscription = subscription else { return } 17 | subscription.cancel() 18 | } 19 | } 20 | 21 | /// Initiate a new state persistor with a given location of the stored data. 22 | /// 23 | /// - Parameter location: The location of the stored data. 24 | public init(location: StatePersistentLocation) { 25 | self.location = location 26 | } 27 | 28 | /// Encode the state to JSON data. 29 | /// 30 | /// - Parameter state: The state 31 | /// - Returns: The encoded state 32 | /// - Throws: This function throws an error if the state could not be encoded. 33 | public func encode(state: State) throws -> Data { 34 | try encoder.encode(state) 35 | } 36 | 37 | /// Decode the JSON data into a new state. 38 | /// 39 | /// - Parameter data: The json data 40 | /// - Returns: The decoded state 41 | /// - Throws: This function throws an error if the state could not be decoded. 42 | public func decode(data: Data) throws -> State { 43 | try decoder.decode(State.self, from: data) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/SwiftDuxExtras/Persistence/LocalStatePersistentLocation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftDux 3 | 4 | fileprivate func getDefaultFileUrl() -> URL { 5 | guard var directoryURL = try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) else { 6 | fatalError("Unable to create default file url for StatePersistor") 7 | } 8 | /// Add project identifier if it exists. 9 | if let identifier = Bundle.main.bundleIdentifier { 10 | directoryURL = directoryURL.appendingPathComponent(identifier) 11 | } 12 | do { 13 | if !FileManager.default.fileExists(atPath: directoryURL.path) { 14 | try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) 15 | } 16 | } catch { 17 | fatalError("Unable to create directory at \(directoryURL.path)") 18 | } 19 | return directoryURL.appendingPathComponent("state") 20 | } 21 | 22 | /// The location of application state within the local filesystem. 23 | /// 24 | /// By default, it stores the application state inside the Application Support directory. 25 | public struct LocalStatePersistentLocation: StatePersistentLocation { 26 | 27 | /// The file location in the local filesystem. 28 | public let fileUrl: URL 29 | 30 | /// Initiate a new location with an optional file url. 31 | /// - Parameter fileUrl: An optional url of the file location. 32 | public init(fileUrl: URL? = nil) { 33 | self.fileUrl = fileUrl ?? getDefaultFileUrl() 34 | } 35 | 36 | /// Save the data to the local filesystem. 37 | /// - Parameter data: The data to save. 38 | /// - Returns: True if the save was successful. 39 | public func save(_ data: Data) -> Bool { 40 | do { 41 | try data.write(to: fileUrl) 42 | return true 43 | } catch { 44 | print("Failed to save state to \(fileUrl)") 45 | return false 46 | } 47 | } 48 | 49 | /// Retrieve the data from the local filesystem. 50 | /// - Returns: The data if successful. 51 | public func restore() -> Data? { 52 | try? Data(contentsOf: fileUrl) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/SwiftDuxExtras/Persistence/PersistStateMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import SwiftDux 4 | 5 | #if canImport(WatchKit) 6 | 7 | import WatchKit 8 | fileprivate let notification: NSNotification.Name? = WKExtension.applicationDidEnterBackgroundNotification 9 | 10 | #elseif canImport(UIKit) 11 | 12 | import UIKit 13 | fileprivate let notification: NSNotification.Name? = UIApplication.didEnterBackgroundNotification 14 | 15 | #elseif canImport(AppKit) 16 | 17 | import AppKit 18 | fileprivate let notification: NSNotification.Name? = NSApplication.willResignActiveNotification 19 | 20 | #else 21 | 22 | fileprivate let notification: NSNotification.Name? = nil 23 | 24 | #endif 25 | 26 | /// Hooks up state peristence to the store. 27 | public final class PersistStateMiddleware: Middleware where SP: StatePersistor, SP.State == State { 28 | private var persistor: SP 29 | private var saveOnChange: Bool 30 | private var interval: RunLoop.SchedulerTimeType.Stride 31 | private var shouldRestore: (State) -> Bool 32 | private var subscriptionCancellable: AnyCancellable? 33 | 34 | /// Initialize a new PersistStateMiddleware. 35 | /// 36 | /// - Parameters: 37 | /// - persistor: The state persistor to use. 38 | /// - saveOnChange: Saves the state when it changes, else, it saves when the app enters the backgroound. 39 | /// - interval: The debounce interval for saving on changes. 40 | /// - shouldRestore: Closure used to validate the state before restoring it. This is useful if the state's schema version has changed. 41 | public init( 42 | _ persistor: SP, 43 | saveOnChange: Bool = true, 44 | debounceFor interval: RunLoop.SchedulerTimeType.Stride = .seconds(1), 45 | shouldRestore: @escaping (State) -> Bool = { _ in true } 46 | ) { 47 | self.persistor = persistor 48 | self.saveOnChange = saveOnChange 49 | self.interval = interval 50 | self.shouldRestore = shouldRestore 51 | } 52 | 53 | public func run(store: StoreProxy, action: Action) -> Action? { 54 | guard case .prepare = action as? StoreAction else { return action } 55 | 56 | if let state = persistor.restore(), shouldRestore(state) { 57 | store.send(StoreAction.reset(state: state)) 58 | } 59 | 60 | if saveOnChange { 61 | subscriptionCancellable = persistor.save(from: store, debounceFor: interval) 62 | } else if let notification = notification { 63 | subscriptionCancellable = NotificationCenter.default 64 | .publisher(for: notification) 65 | .debounce(for: interval, scheduler: RunLoop.main) 66 | .compactMap { _ in store.state } 67 | .persist(with: persistor) 68 | } else { 69 | print("Failed to initiate persistence using default notifiation center.") 70 | } 71 | 72 | return action 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/SwiftDuxExtras/Persistence/PersistSubscriber.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// Subscribes to a publisher of actions, and sends them to an action dispatcher. 5 | final public class PersistSubscriber: Subscriber where Persistor: StatePersistor, Persistor.State == Input { 6 | 7 | public typealias ReceivedCompletion = (Subscribers.Completion) -> Void 8 | 9 | let persistor: Persistor 10 | var subscription: Subscription? = nil { 11 | willSet { 12 | guard let subscription = subscription else { return } 13 | subscription.cancel() 14 | } 15 | } 16 | 17 | init(persistor: Persistor) { 18 | self.persistor = persistor 19 | } 20 | 21 | public func receive(subscription: Subscription) { 22 | self.subscription = subscription 23 | subscription.request(.max(1)) 24 | } 25 | 26 | public func receive(_ input: Input) -> Subscribers.Demand { 27 | if persistor.save(input) { 28 | return .max(1) 29 | } 30 | return .none 31 | } 32 | 33 | public func receive(completion: Subscribers.Completion) { 34 | subscription = nil 35 | } 36 | 37 | public func cancel() { 38 | subscription?.cancel() 39 | subscription = nil 40 | } 41 | 42 | } 43 | 44 | extension Publisher where Output: Codable, Failure == Never { 45 | 46 | /// Subscribe to a publisher of actions, and send the results to an action dispatcher. 47 | /// - Parameter persistor: The state persistor to save the results to. 48 | /// - Returns: A cancellable to unsubscribe. 49 | public func persist

(with persistor: P) -> AnyCancellable where P: StatePersistor, P.State == Output { 50 | let subscriber = PersistSubscriber(persistor: persistor) 51 | 52 | self.subscribe(subscriber) 53 | return AnyCancellable { [subscriber] in subscriber.cancel() } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/SwiftDuxExtras/Persistence/StatePersistentLocation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The stored location of the application state. 4 | /// 5 | /// This is used by the state persistor to store or retreieve the state from 6 | /// a storage location. 7 | public protocol StatePersistentLocation { 8 | 9 | /// Save the state data to storage. 10 | /// - Parameter data: The data to save. 11 | /// - Returns: True if the save was successful. 12 | func save(_ data: Data) -> Bool 13 | 14 | /// Retreive the state from storage. 15 | /// - Returns: Data if successful. 16 | func restore() -> Data? 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SwiftDuxExtras/Persistence/StatePersistor.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import SwiftDux 4 | 5 | /// Persists and restores application state. 6 | public protocol StatePersistor { 7 | 8 | /// The type of application state to persist. 9 | associatedtype State: Codable 10 | 11 | /// The location where the state will be stored. 12 | var location: StatePersistentLocation { get } 13 | 14 | /// Initiate a new persistor for the give location. 15 | /// 16 | /// - Parameter location: The location where the data will be saved and restored from. 17 | init(location: StatePersistentLocation) 18 | 19 | /// Encodes the state into a raw data object. 20 | /// 21 | /// - Parameter state: The state to encode 22 | /// - Returns: The encoded state. 23 | /// - Throws: This function throws an error if the state could not be encoded. 24 | func encode(state: State) throws -> Data 25 | 26 | /// Decode raw data into a new state object. 27 | /// 28 | /// - Parameter data: The data to decode. 29 | /// - Returns: The decoded state 30 | /// - Throws: This function throws an error if the state could not be decoded. 31 | func decode(data: Data) throws -> State 32 | 33 | } 34 | 35 | extension StatePersistor { 36 | 37 | /// Initiate a new json persistor with a given location of the stored data on the local file system. 38 | /// 39 | /// - Parameter fileUrl: The url where the state will be saved and restored from on the local file system. 40 | public init(fileUrl: URL? = nil) { 41 | self.init(location: LocalStatePersistentLocation(fileUrl: fileUrl)) 42 | } 43 | 44 | /// Save the state object to a storage location. 45 | /// 46 | /// - Parameter state: The state to save. 47 | /// - Returns: True if successful. 48 | @discardableResult 49 | public func save(_ state: State) -> Bool { 50 | do { 51 | let data = try encode(state: state) 52 | return location.save(data) 53 | } catch { 54 | return false 55 | } 56 | } 57 | 58 | /// Restore the state from storage. 59 | /// 60 | /// - Returns: The state if successful. 61 | public func restore() -> State? { 62 | guard let data = location.restore() else { return nil } 63 | do { 64 | return try decode(data: data) 65 | } catch { 66 | return nil 67 | } 68 | } 69 | 70 | /// Subscribe to a store to save the state automatically. 71 | /// 72 | /// - Parameters 73 | /// - store: The store to subsctibe to. 74 | /// - interval: The time interval to debounce the updates against. 75 | /// - Returns: A cancellable to unsubscribe from the store. 76 | public func save( 77 | from store: Store, 78 | debounceFor interval: RunLoop.SchedulerTimeType.Stride = .seconds(1) 79 | ) -> AnyCancellable { 80 | store.didChange 81 | .debounce(for: interval, scheduler: RunLoop.main) 82 | .compactMap { [weak store] in store?.state } 83 | .persist(with: self) 84 | } 85 | 86 | /// Subscribe to a store to save the state automatically. 87 | /// 88 | /// - Parameters 89 | /// - store: The store to subsctibe to. 90 | /// - interval: The time interval to debounce the updates against. 91 | /// - Returns: A cancellable to unsubscribe from the store. 92 | public func save( 93 | from store: StoreProxy, 94 | debounceFor interval: RunLoop.SchedulerTimeType.Stride = .seconds(1) 95 | ) -> AnyCancellable { 96 | store.didChange 97 | .debounce(for: interval, scheduler: RunLoop.main) 98 | .compactMap { _ in store.state } 99 | .persist(with: self) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/SwiftDuxExtras/PrintActionMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftDux 3 | 4 | /// Default printer for the `PrintActionMiddleware<_>` 5 | fileprivate func defaultActionPrinter(_ actionDescription: String) { 6 | print(actionDescription) 7 | } 8 | 9 | /// A simple middlware that prints the description of the latest action. 10 | public final class PrintActionMiddleware: Middleware { 11 | public var printer: ((String) -> Void) = defaultActionPrinter 12 | public var filter: (Action) -> Bool = { _ in true } 13 | 14 | /// Initialize a new PrinterActionMiddleware. 15 | /// 16 | /// - Parameters: 17 | /// - printer: A custom printer for the action's discription. Defaults to print(). 18 | /// - filter: Filter what actions get printed. 19 | public init(printer: ((String) -> Void)? = nil, filter: @escaping (Action) -> Bool = { _ in true }) { 20 | self.printer = printer ?? defaultActionPrinter 21 | self.filter = filter 22 | } 23 | 24 | public func run(store: StoreProxy, action: Action) -> Action? { 25 | if filter(action) { 26 | printer(String(describing: action)) 27 | } 28 | 29 | return action 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import SwiftDuxTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += StoreTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/Action/ActionPlanTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Combine 3 | import Dispatch 4 | @testable import SwiftDux 5 | 6 | final class ActionPlanTests: XCTestCase { 7 | var store: Store! 8 | var sentActions: [TestAction] = [] 9 | 10 | override func setUp() { 11 | store = Store(state: TestState(), reducer: TestReducer(), middleware: 12 | HandleActionMiddleware { [weak self] store, action in 13 | if let action = action as? TestAction { 14 | self?.sentActions.append(action) 15 | } 16 | return action 17 | } 18 | ) 19 | sentActions = [] 20 | } 21 | 22 | func assertActionsWereSent(_ expected: [TestAction]) { 23 | XCTAssertEqual(sentActions, expected) 24 | } 25 | 26 | func testEmptyActionPlan() { 27 | let actionPlan = ActionPlan { _ in } 28 | store.send(actionPlan) 29 | assertActionsWereSent([]) 30 | } 31 | 32 | func testBasicActionPlan() { 33 | let actionPlan = ActionPlan { 34 | $0.send(TestAction.actionA) 35 | } 36 | store.send(actionPlan) 37 | assertActionsWereSent([TestAction.actionA]) 38 | } 39 | 40 | func testActionPlanWithMultipleSends() { 41 | let actionPlan = ActionPlan { 42 | $0.send(TestAction.actionA) 43 | $0.send(TestAction.actionB) 44 | $0.send(TestAction.actionA) 45 | } 46 | store.send(actionPlan) 47 | assertActionsWereSent([ 48 | TestAction.actionA, 49 | TestAction.actionB, 50 | TestAction.actionA 51 | ]) 52 | } 53 | 54 | func testPublishableActionPlan() { 55 | let actionPlan = ActionPlan { _ in 56 | [TestAction.actionB, TestAction.actionA].publisher 57 | } 58 | let cancellable = store.sendAsCancellable(actionPlan) 59 | 60 | assertActionsWereSent([ 61 | TestAction.actionB, 62 | TestAction.actionA 63 | ]) 64 | 65 | cancellable.cancel() 66 | } 67 | 68 | func testCancellableActionPlan() { 69 | let expectation = XCTestExpectation(description: "Expect one cancellation") 70 | expectation.isInverted = true 71 | 72 | let actionPlan = ActionPlan { store in 73 | Just(TestAction.actionB) 74 | .delay(for: .seconds(1), scheduler: RunLoop.main) 75 | .handleEvents(receiveOutput: { action in 76 | expectation.fulfill() 77 | }) 78 | } 79 | 80 | let cancellable = store.sendAsCancellable(actionPlan) 81 | 82 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 83 | cancellable.cancel() 84 | //expectation.fulfill() 85 | } 86 | 87 | wait(for: [expectation], timeout: 5.0) 88 | } 89 | 90 | func testChainedActionPlans() { 91 | let actionPlanA = ActionPlan { store in 92 | store.send(TestAction.actionB) 93 | } 94 | let actionPlanB = ActionPlan { store in 95 | store.send(TestAction.actionA) 96 | } 97 | let actionPlanC = ActionPlan { store in 98 | store.send(TestAction.actionB) 99 | } 100 | let chainedActionPlan = actionPlanA + actionPlanB + actionPlanC 101 | 102 | _ = store.sendAsCancellable(chainedActionPlan) 103 | 104 | assertActionsWereSent([ 105 | TestAction.actionB, 106 | TestAction.actionA, 107 | TestAction.actionB 108 | ]) 109 | } 110 | 111 | func testChainedActionPlansWithPublisher() { 112 | let actionPlanA = ActionPlan { store -> AnyPublisher in 113 | Just(TestAction.actionB).delay(for: .milliseconds(10), scheduler: RunLoop.main).eraseToAnyPublisher() 114 | } 115 | let actionPlanB = ActionPlan { store in 116 | store.send(TestAction.actionA) 117 | } 118 | let actionPlanC = ActionPlan { store in 119 | Just(TestAction.actionB) 120 | } 121 | let expectation = XCTestExpectation(description: "Expect one cancellation") 122 | let chainedActionPlan = actionPlanA.then(actionPlanB).then(actionPlanC).then { 123 | expectation.fulfill() 124 | } 125 | 126 | store.send(chainedActionPlan) 127 | 128 | wait(for: [expectation], timeout: 10.0) 129 | 130 | assertActionsWereSent([ 131 | TestAction.actionB, 132 | TestAction.actionA, 133 | TestAction.actionB 134 | ]) 135 | } 136 | 137 | static var allTests = [ 138 | ("testBasicActionPlan", testBasicActionPlan), 139 | ("testBasicActionPlan", testBasicActionPlan), 140 | ("testActionPlanWithMultipleSends", testActionPlanWithMultipleSends), 141 | ("testPublishableActionPlan", testPublishableActionPlan), 142 | ("testChainedActionPlansWithPublisher", testChainedActionPlansWithPublisher), 143 | ] 144 | } 145 | 146 | extension ActionPlanTests { 147 | 148 | enum TestAction: Action, Equatable { 149 | case actionA 150 | case actionB 151 | } 152 | 153 | struct TestState: Equatable {} 154 | 155 | class TestReducer: Reducer { 156 | func reduce(state: TestState, action: TestAction) -> TestState { 157 | state 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/Middleware/CompositeMiddlwareTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Combine 3 | @testable import SwiftDux 4 | 5 | final class CompositeMiddlewareTests: XCTestCase { 6 | 7 | func testCompiningMiddleware() { 8 | let middlewareA = MiddlewareA() 9 | let middlewareB = MiddlewareB() 10 | let store = Store(state: TestState(), reducer: TestReducer(), middleware: middlewareA + middlewareB) 11 | store.send(TestAction.setTextUnmodified("123")) 12 | XCTAssertEqual( 13 | store.state.text, 14 | "123" 15 | ) 16 | store.send(TestAction.setText("123")) 17 | XCTAssertEqual( 18 | store.state.text, 19 | "123AB" 20 | ) 21 | } 22 | 23 | static var allTests = [ 24 | ("testCombiningReducers", testCompiningMiddleware) 25 | ] 26 | } 27 | 28 | extension CompositeMiddlewareTests { 29 | 30 | struct TestState: Equatable { 31 | var text: String = "" 32 | } 33 | 34 | enum TestAction: Action { 35 | case setText(String) 36 | case setTextUnmodified(String) 37 | } 38 | 39 | final class TestReducer: Reducer { 40 | func reduce(state: TestState, action: TestAction) -> TestState { 41 | var state = state 42 | switch action { 43 | case .setText(let text): 44 | state.text = text 45 | case .setTextUnmodified(let text): 46 | state.text = text 47 | } 48 | return state 49 | } 50 | } 51 | 52 | final class MiddlewareA: Middleware { 53 | func run(store: StoreProxy, action: Action) -> Action? { 54 | if case .setText(let text) = action as? TestAction { 55 | return TestAction.setText(text + "A") 56 | } 57 | return action 58 | } 59 | } 60 | 61 | final class MiddlewareB: Middleware { 62 | func run(store: StoreProxy, action: Action) -> Action? { 63 | if case .setText(let text) = action as? TestAction { 64 | return TestAction.setText(text + "B") 65 | } 66 | return action 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/PerformanceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Combine 3 | @testable import SwiftDux 4 | 5 | final class PerformanceTests: XCTestCase { 6 | 7 | func testOrderedStatePerformance() { 8 | measure { 9 | let store = configureStore() 10 | for i in 0...10000 { 11 | store.send(TodosAction.addTodo(toList: "123", withText: "Todo item \(i)")) 12 | } 13 | XCTAssertEqual(10004, store.state.todoLists["123"]?.todos.count) 14 | 15 | let firstMoveItem = store.state.todoLists["123"]?.todos.values[300] 16 | store.send(TodosAction.moveTodos(inList: "123", from: IndexSet(300...5000), to: 8000)) 17 | XCTAssertEqual(firstMoveItem?.id, store.state.todoLists["123"]?.todos.values[3299].id) 18 | 19 | let firstUndeletedItem = store.state.todoLists["123"]?.todos.values[3001] 20 | store.send(TodosAction.removeTodos(fromList: "123", at: IndexSet(100...3000))) 21 | XCTAssertEqual(firstUndeletedItem?.id, store.state.todoLists["123"]?.todos.values[100].id) 22 | } 23 | } 24 | 25 | func testStoreUpdatePerformance() { 26 | let subsriberCount = 1000 27 | let sendCount = 1000 28 | var updateCounts = 0 29 | var sinks = [Cancellable]() 30 | let store = configureStore() 31 | 32 | for _ in 1...subsriberCount { 33 | sinks.append(store.didChange.sink { _ in updateCounts += 1 }) 34 | } 35 | 36 | measure { 37 | updateCounts = 0 38 | for _ in 1...sendCount { 39 | store.send(TodosAction.doNothing) 40 | } 41 | XCTAssertEqual(updateCounts, subsriberCount * sendCount) 42 | } 43 | 44 | // Needed so it doesn't get optimized away. 45 | XCTAssertEqual(sinks.count, subsriberCount) 46 | } 47 | 48 | static var allTests = [ 49 | ("testOrderedStatePerformance", testOrderedStatePerformance), 50 | ("testStoreUpdatePerformance", testStoreUpdatePerformance), 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/Reducer/CompositeReducerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Combine 3 | @testable import SwiftDux 4 | 5 | final class CompositeReducerTests: XCTestCase { 6 | 7 | func testCombiningReducers() { 8 | let reducerA = ReducerA() 9 | let reducerB = ReducerB() 10 | let reducer = reducerA + reducerB 11 | XCTAssertEqual( 12 | reducer.reduceAny(state: TestState(), action: TestAction.setStateA("123")), 13 | TestState(stateA: "123") 14 | ) 15 | XCTAssertEqual( 16 | reducer.reduceAny(state: TestState(), action: TestAction.setStateB("321")), 17 | TestState(stateB: "321") 18 | ) 19 | } 20 | 21 | static var allTests = [ 22 | ("testCombiningReducers", testCombiningReducers) 23 | ] 24 | } 25 | 26 | extension CompositeReducerTests { 27 | 28 | struct TestState: Equatable { 29 | var stateA: String = "" 30 | var stateB: String = "" 31 | } 32 | 33 | enum TestAction: Action { 34 | case setStateA(String) 35 | case setStateB(String) 36 | } 37 | 38 | final class ReducerA: Reducer { 39 | func reduce(state: TestState, action: TestAction) -> TestState { 40 | var state = state 41 | switch action { 42 | case .setStateA(let stateA): 43 | state.stateA = stateA 44 | default: 45 | break; 46 | } 47 | return state 48 | } 49 | } 50 | 51 | final class ReducerB: Reducer { 52 | func reduce(state: TestState, action: TestAction) -> TestState { 53 | var state = state 54 | switch action { 55 | case .setStateB(let stateB): 56 | state.stateB = stateB 57 | default: 58 | break; 59 | } 60 | return state 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/State/OrderedStateTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Combine 3 | import Dispatch 4 | @testable import SwiftDux 5 | 6 | final class OrderedStateTests: XCTestCase { 7 | let bob = User(id: "2", name: "Bob") 8 | let bill = User(id: "3", name: "Bill") 9 | let john = User(id: "1", name: "John") 10 | 11 | override func setUp() { 12 | } 13 | 14 | func testInitializeWithArray() { 15 | let state = OrderedState([bob, bill, john]) 16 | XCTAssertEqual(state.values, [bob, bill, john]) 17 | } 18 | 19 | func testInitializeWithVariadicArguments() { 20 | let state = OrderedState(bob, bill, john) 21 | XCTAssertEqual(state.values, [bob, bill, john]) 22 | } 23 | 24 | func testInitializeFromDecoder() { 25 | let json = #"[{ "id": "1", "name": "John" }, { "id": "2", "name": "Bob" }, { "id": "3", "name": "Bill" }]"# 26 | let data = json.data(using: .utf8)! 27 | let decoder = JSONDecoder() 28 | let state = try! decoder.decode(OrderedState.self, from: data); 29 | XCTAssertEqual(state.values, [john, bob, bill]) 30 | } 31 | 32 | func testEncode() { 33 | let encoder = JSONEncoder() 34 | let state = OrderedState(john, bob, bill) 35 | let json = try! encoder.encode(state) 36 | XCTAssertEqual( 37 | String(decoding: json, as: UTF8.self), 38 | #"[{"id":"1","name":"John"},{"id":"2","name":"Bob"},{"id":"3","name":"Bill"}]"# 39 | ) 40 | } 41 | 42 | func testAppendNewItem() { 43 | var state = OrderedState(bob, bill) 44 | state.append(john) 45 | XCTAssertEqual(state.values, [bob, bill, john]) 46 | } 47 | 48 | func testAppendExistingItem() { 49 | var state = OrderedState(bob, john, bill) 50 | state.append(john) 51 | XCTAssertEqual(state.values, [bob, bill, john]) 52 | } 53 | 54 | func testPrepend() { 55 | var state = OrderedState(bob, bill) 56 | state.prepend(john) 57 | XCTAssertEqual(state.values, [john, bob, bill]) 58 | } 59 | 60 | func testInsertNewItem() { 61 | var state = OrderedState(bob, bill) 62 | state.insert(john, at: 1) 63 | XCTAssertEqual(state.values, [bob, john, bill]) 64 | } 65 | 66 | func testInsertExistingItem() { 67 | var state = OrderedState(bob, bill, john) 68 | state.insert(john, at: 1) 69 | XCTAssertEqual(state.values, [bob, john, bill]) 70 | } 71 | 72 | func testRemove() { 73 | var state = OrderedState(bob, bill, john) 74 | state.remove(at: 1) 75 | XCTAssertEqual(state.values, [bob, john]) 76 | } 77 | 78 | func testRemoveIndexSet() { 79 | var state = OrderedState(bob, bill, john) 80 | state.remove(at: IndexSet([0,2])) 81 | XCTAssertEqual(state.values, [bill]) 82 | } 83 | 84 | func testMoveOneUserFoward() { 85 | var state = OrderedState(bob, bill, john) 86 | state.move(from: IndexSet([1]), to: 3) 87 | XCTAssertEqual(state.values, [bob, john, bill]) 88 | } 89 | 90 | func testMoveOneUserBackwards() { 91 | var state = OrderedState(bob, bill, john) 92 | state.move(from: IndexSet([2]), to: 0) 93 | XCTAssertEqual(state.values, [john, bob, bill]) 94 | } 95 | 96 | func testMoveTwoUsersFoward() { 97 | var state = OrderedState(bob, bill, john) 98 | state.move(from: IndexSet([0,1]), to: 3) 99 | XCTAssertEqual(state.values, [john, bob, bill]) 100 | } 101 | 102 | func testMoveTwoUsersBackwards() { 103 | var state = OrderedState(bob, bill, john) 104 | state.move(from: IndexSet([1,2]), to: 0) 105 | XCTAssertEqual(state.values, [bill, john, bob]) 106 | } 107 | 108 | func testMoveAllUsers() { 109 | var state = OrderedState(bob, bill, john) 110 | state.move(from: IndexSet([0,1, 2]), to: 2) 111 | XCTAssertEqual(state.values, [bob, bill, john]) 112 | } 113 | 114 | func testSort() { 115 | var state = OrderedState(john, bob, bill) 116 | state.sort { $0.name < $1.name } 117 | XCTAssertEqual(state.values, [bill, bob, john]) 118 | } 119 | 120 | func testSorted() { 121 | let state = OrderedState(john, bob, bill).sorted { $0.name < $1.name } 122 | XCTAssertEqual(state.values, [bill, bob, john]) 123 | } 124 | 125 | func testFilter() { 126 | let results = OrderedState(john, bob, bill).filter { $0.name == "Bob" } 127 | XCTAssertEqual(results, [bob]) 128 | } 129 | 130 | func testIndexSubscript() { 131 | let state = OrderedState(john, bob, bill).sorted { $0.name < $1.name } 132 | XCTAssertEqual(state[2], john) 133 | } 134 | 135 | func testIdSubscript() { 136 | let state = OrderedState(john, bob, bill).sorted { $0.name < $1.name } 137 | XCTAssertEqual(state["2"], bob) 138 | } 139 | 140 | func testIdSubscriptWithInteger() { 141 | let state = OrderedState( 142 | Fruit(id: 1, name: "apple"), 143 | Fruit(id: 2, name: "orange"), 144 | Fruit(id: 3, name: "banana") 145 | ) 146 | XCTAssertEqual(state[2], Fruit(id: 3, name: "banana")) 147 | XCTAssertEqual(state.value(forId: 2), Fruit(id: 2, name: "orange")) 148 | } 149 | 150 | func testEquality() { 151 | XCTAssertNotEqual(OrderedState(bob, john, bill), OrderedState(john, bob, bill)) 152 | XCTAssertEqual(OrderedState(john, bob, bill), OrderedState(john, bob, bill)) 153 | } 154 | 155 | static var allTests = [ 156 | ("testInitializeWithArray", testInitializeWithArray), 157 | ("testInitializeWithVariadicArguments", testInitializeWithVariadicArguments), 158 | ("testEncode", testEncode), 159 | ("testAppendNewItem", testAppendNewItem), 160 | ("testAppendExistingItem", testAppendExistingItem), 161 | ("testPrepend", testPrepend), 162 | ("testInsertNewItem", testInsertNewItem), 163 | ("testInsertExistingItem", testInsertExistingItem), 164 | ("testRemove", testRemove), 165 | ("testRemoveIndexSet", testRemoveIndexSet), 166 | ("testMoveOneUserFoward", testMoveOneUserFoward), 167 | ("testMoveOneUserBackwards", testMoveOneUserBackwards), 168 | ("testMoveTwoUsersFoward", testMoveTwoUsersFoward), 169 | ("testMoveTwoUsersBackwards", testMoveTwoUsersBackwards), 170 | ("testMoveAllUsers", testMoveAllUsers), 171 | ("testSort", testSort), 172 | ("testSorted", testSorted), 173 | ("testFilter", testFilter), 174 | ("testIndexSubscript", testIndexSubscript), 175 | ("testIdSubscript", testIdSubscript), 176 | ("testIdSubscriptWithInteger", testIdSubscriptWithInteger), 177 | ("testEquality", testEquality), 178 | ] 179 | } 180 | 181 | extension OrderedStateTests { 182 | 183 | struct User: Identifiable, Codable, Equatable { 184 | var id: String 185 | var name: String 186 | } 187 | 188 | struct Fruit: Identifiable, Codable, Equatable { 189 | var id: Double 190 | var name: String 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/Store/StoreProxyTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Combine 3 | @testable import SwiftDux 4 | 5 | final class StoreProxyTests: XCTestCase { 6 | 7 | func testAccessingState() { 8 | let store = configureStore() 9 | let proxy = store.proxy(for: AppState.self) 10 | XCTAssertEqual(proxy?.state.todoLists["123"]?.name, "Shopping List") 11 | } 12 | 13 | func testSendingAction() { 14 | let store = configureStore() 15 | let proxy = store.proxy(for: AppState.self) 16 | proxy?.send(TodoListsAction.addTodoList(name: "test")) 17 | XCTAssertEqual(proxy?.state.todoLists[1].name, "test") 18 | } 19 | 20 | func testProxyWithProtocol() { 21 | let store = configureStore() 22 | let proxy = store.proxy(for: TodoListStateRoot.self) 23 | XCTAssertNotNil(proxy) 24 | } 25 | 26 | static var allTests = [ 27 | ("testAccessingState", testAccessingState), 28 | ("testSendingAction", testSendingAction), 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/Store/StoreTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Combine 3 | @testable import SwiftDux 4 | 5 | final class StoreTests: XCTestCase { 6 | var store: Store! 7 | 8 | override func setUp() { 9 | store = Store(state: TestSendingState(text: "initial text"), reducer: TestSendingReducer()) 10 | } 11 | 12 | override func tearDown() { 13 | store = nil 14 | } 15 | 16 | func testInitialStateValue() { 17 | XCTAssertEqual(store.state.text, "initial text") 18 | } 19 | 20 | func testSendingAction() { 21 | store.send(TestSendingAction.setText("New text")) 22 | XCTAssertEqual(store.state.text, "New text") 23 | } 24 | 25 | func testActionPlans() { 26 | store.send(ActionPlan { store in 27 | Just(TestSendingAction.setText("1234")) 28 | }) 29 | XCTAssertEqual(store.state.text, "1234") 30 | } 31 | 32 | func testSubscribingToActionPlans() { 33 | store.send(ActionPlan { store in 34 | Just(TestSendingAction.setText("1234")) 35 | }) 36 | XCTAssertEqual(store.state.text, "1234") 37 | } 38 | 39 | func testSubscribingToComplexActionPlans() { 40 | store.send(ActionPlan { store in 41 | Just(store.state.value) 42 | .map { value -> Int in 43 | store.send(TestSendingAction.setValue(value + 1)) 44 | return store.state.value 45 | } 46 | .map { value -> Int in 47 | store.send(TestSendingAction.setValue(value + 1)) 48 | return store.state.value 49 | } 50 | .map { value -> Action in 51 | TestSendingAction.setValue(value + 1) 52 | } 53 | }) 54 | XCTAssertEqual(store.state.value, 3) 55 | } 56 | 57 | func testFutureAction() { 58 | let actionPlan = ActionPlan { store in 59 | Future { promise in 60 | store.send(TestSendingAction.setText("test")) 61 | promise(.success(())) 62 | } 63 | } 64 | 65 | let cancellable = actionPlan.run(store: store.proxy()).send(to: store) 66 | XCTAssertEqual(store.state.text, "test") 67 | cancellable.cancel() 68 | } 69 | 70 | static var allTests = [ 71 | ("testSubscribingToActionPlans", testSubscribingToActionPlans), 72 | ("testSubscribingToActionPlans", testSubscribingToActionPlans), 73 | ("testSubscribingToComplexActionPlans", testSubscribingToComplexActionPlans), 74 | ("testStoreCleansUpSubscriptions", testFutureAction), 75 | ] 76 | } 77 | 78 | extension StoreTests { 79 | 80 | enum TestSendingAction: Action { 81 | case setText(String) 82 | case setValue(Int) 83 | } 84 | 85 | struct TestSendingState: Equatable { 86 | var text: String 87 | var value: Int = 0 88 | } 89 | 90 | class TestSendingReducer: Reducer { 91 | func reduce(state: TestSendingState, action: TestSendingAction) -> TestSendingState { 92 | var state = state 93 | switch action { 94 | case .setText(let text): 95 | state.text = text 96 | case .setValue(let value): 97 | state.value = value 98 | } 99 | return state 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/StoreActionDispatcherTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Combine 3 | @testable import SwiftDux 4 | 5 | final class StoreActionDispatcherTests: XCTestCase { 6 | 7 | func testBasicActionDispatchingValue() { 8 | let store = configureStore() 9 | store.send(TodosAction.addTodo(toList: "123", withText: "My Todo")) 10 | XCTAssertEqual(store.state.todoLists["123"]?.todos.filter { $0.text == "My Todo"}.count, 1) 11 | } 12 | 13 | static var allTests = [ 14 | ("testBasicActionDispatchingValue", testBasicActionDispatchingValue) 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/SwiftDuxExtras/Persistence/JSONStatePersistorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONStatePersistorTests.swift 3 | // SwiftDuxTests 4 | // 5 | // Created by Steven Lambion on 1/14/20. 6 | // 7 | 8 | import XCTest 9 | import SwiftDux 10 | import Combine 11 | @testable import SwiftDuxExtras 12 | 13 | class JSONStatePersistorTests: XCTestCase { 14 | var location: TestLocation! 15 | 16 | override func setUp() { 17 | location = TestLocation() 18 | } 19 | 20 | override func tearDown() { 21 | // Put teardown code here. This method is called after the invocation of each test method in the class. 22 | } 23 | 24 | func testSaveState() { 25 | let persistor = JSONStatePersistor(location: location) 26 | persistor.save(TestState(name: "Bob")) 27 | XCTAssertEqual(String(data: location.savedData!, encoding: .utf8), #"{"name":"Bob"}"#) 28 | } 29 | 30 | func testSaveStateWithPubisher() { 31 | let persistor = JSONStatePersistor(location: location) 32 | let expectation = XCTestExpectation() 33 | let cancellable = location.didSave.dropFirst().sink { expectation.fulfill() } 34 | let publisher = [TestState(name: "John"), TestState(name: "Bob")].publisher 35 | .delay(for: .milliseconds(10), scheduler: RunLoop.main) 36 | let persistCancellable = publisher.persist(with: persistor) 37 | 38 | wait(for: [expectation], timeout: 10) 39 | cancellable.cancel() 40 | persistCancellable.cancel() 41 | XCTAssertEqual(persistor.restore(), TestState(name: "Bob")) 42 | } 43 | 44 | func testSaveStateFromStore() { 45 | let persistor = JSONStatePersistor(location: location) 46 | let store = Store(state: TestState(), reducer: TestReducer()) 47 | let expectation = XCTestExpectation() 48 | let cancellable = location.didSave.first().sink { expectation.fulfill() } 49 | let persistCancellable = persistor.save(from: store) 50 | 51 | store.send(TestAction.setName("John")) 52 | store.send(TestAction.setName("Bilbo")) 53 | 54 | wait(for: [expectation], timeout: 10) 55 | cancellable.cancel() 56 | persistCancellable.cancel() 57 | XCTAssertEqual(persistor.restore(), TestState(name: "Bilbo")) 58 | } 59 | 60 | func testRestoreState() { 61 | let persistor = JSONStatePersistor(location: location) 62 | persistor.save(TestState(name: "Bob")) 63 | XCTAssertEqual(persistor.restore(), TestState(name: "Bob")) 64 | } 65 | 66 | static var allTests = [ 67 | ("testSaveState", testSaveState), 68 | ("testSaveStateWithPubisher", testSaveStateWithPubisher), 69 | ("testSaveStateFromStore", testSaveStateFromStore), 70 | ("testRestoreState", testRestoreState), 71 | ] 72 | } 73 | 74 | extension JSONStatePersistorTests { 75 | 76 | enum TestAction: Action { 77 | case setName(String) 78 | } 79 | 80 | struct TestState: Equatable & Codable { 81 | var name: String = "" 82 | } 83 | 84 | class TestReducer: Reducer { 85 | 86 | func reduce(state: TestState, action: TestAction) -> TestState { 87 | switch action { 88 | case .setName(let name): 89 | return TestState(name: name) 90 | } 91 | } 92 | } 93 | 94 | final class TestLocation: StatePersistentLocation { 95 | 96 | var savedData: Data? = nil 97 | var canSave: Bool = true 98 | 99 | var didSave = PassthroughSubject() 100 | 101 | func save(_ data: Data) -> Bool { 102 | guard canSave == true else { return false } 103 | self.savedData = data 104 | self.didSave.send() 105 | return true 106 | } 107 | 108 | func restore() -> Data? { 109 | savedData 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/SwiftDuxExtras/Persistence/PersistStateMiddlewareTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONStatePersistorTests.swift 3 | // SwiftDuxTests 4 | // 5 | // Created by Steven Lambion on 1/14/20. 6 | // 7 | 8 | import XCTest 9 | import SwiftDux 10 | import Combine 11 | @testable import SwiftDuxExtras 12 | 13 | class PersistStateMiddlewareTests: XCTestCase { 14 | var location: TestLocation! 15 | var persistor: JSONStatePersistor! 16 | 17 | override func setUp() { 18 | location = TestLocation() 19 | persistor = JSONStatePersistor(location: location) 20 | } 21 | 22 | override func tearDown() { 23 | // Put teardown code here. This method is called after the invocation of each test method in the class. 24 | } 25 | 26 | func createStore(with middleware: M) -> Store where M: Middleware, M.State == TestState { 27 | Store(state: TestState(), reducer: TestReducer(), middleware: middleware) 28 | } 29 | 30 | func testSaveState() { 31 | let store = createStore(with: PersistStateMiddleware(persistor)) 32 | let expectation = XCTestExpectation() 33 | let cancellable = location.savedData.dropFirst().compactMap { $0 }.sink { _ in expectation.fulfill() } 34 | store.send(TestAction.setName("John")) 35 | wait(for: [expectation], timeout: 10.0) 36 | XCTAssertEqual(String(data: location.savedData.value, encoding: .utf8), #"{"name":"John"}"#) 37 | XCTAssertNotNil(cancellable) 38 | } 39 | 40 | func testRestoreState() { 41 | let store = createStore(with: PersistStateMiddleware(persistor)) 42 | XCTAssertEqual(store.state.name, "Rose") 43 | } 44 | 45 | static var allTests = [ 46 | ("testSaveState", testSaveState), 47 | ("testRestoreState", testRestoreState), 48 | ] 49 | } 50 | 51 | extension PersistStateMiddlewareTests { 52 | 53 | enum TestAction: Action { 54 | case setName(String) 55 | } 56 | 57 | struct TestState: Equatable & Codable { 58 | var name: String = "" 59 | } 60 | 61 | class TestReducer: Reducer { 62 | 63 | func reduce(state: TestState, action: TestAction) -> TestState { 64 | switch action { 65 | case .setName(let name): 66 | return TestState(name: name) 67 | } 68 | } 69 | } 70 | 71 | final class TestLocation: StatePersistentLocation { 72 | 73 | var savedData = CurrentValueSubject(try! JSONEncoder().encode(TestState(name: "Rose"))) 74 | var canSave: Bool = true 75 | 76 | var didSave = PassthroughSubject() 77 | 78 | func save(_ data: Data) -> Bool { 79 | guard canSave == true else { return false } 80 | self.savedData.send(data) 81 | self.didSave.send() 82 | return true 83 | } 84 | 85 | func restore() -> Data? { 86 | savedData.value 87 | } 88 | 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/SwiftDuxExtras/PrintActionMiddlewareTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Combine 3 | import Dispatch 4 | import SwiftDux 5 | @testable import SwiftDuxExtras 6 | 7 | final class PrintActionMiddlewareTests: XCTestCase { 8 | 9 | override func setUp() { 10 | } 11 | 12 | func testPrintAction() { 13 | var log = [String]() 14 | let store = Store( 15 | state: TestState(), 16 | reducer: TestReducer(), 17 | middleware: PrintActionMiddleware(printer: { log.append($0) }) 18 | ) 19 | store.send(TestAction.actionB) 20 | XCTAssertEqual(log, ["prepare", "actionB"]) 21 | } 22 | 23 | static var allTests = [ 24 | ("testPrintAction", testPrintAction), 25 | ] 26 | } 27 | 28 | extension PrintActionMiddlewareTests { 29 | 30 | enum TestAction: Action, Equatable { 31 | case actionA 32 | case actionB 33 | } 34 | 35 | struct TestState: Equatable { 36 | var test: String = "" 37 | } 38 | 39 | class TestReducer: Reducer { 40 | func reduce(state: TestState, action: TestAction) -> TestState { 41 | state 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/TestState/TestReducer.swift: -------------------------------------------------------------------------------- 1 | import SwiftDux 2 | import Foundation 3 | 4 | enum TodoListsAction: Action { 5 | case addTodoList(name: String) 6 | case removeTodoLists(at: IndexSet) 7 | case moveTodoLists(from: IndexSet, to: Int) 8 | } 9 | 10 | enum TodosAction: Action { 11 | case addTodo(toList: String, withText: String) 12 | case removeTodos(fromList: String, at: IndexSet) 13 | case moveTodos(inList: String, from: IndexSet, to: Int) 14 | case doNothing 15 | } 16 | 17 | final class TodoListsReducer: Reducer { 18 | 19 | func reduce(state: AppState, action: TodoListsAction) -> AppState { 20 | var state = state 21 | switch action { 22 | case .addTodoList(let name): 23 | state.todoLists.append( 24 | TodoListState(id: UUID().uuidString, name: name, todos: OrderedState()) 25 | ) 26 | case .removeTodoLists(let indexSet): 27 | state.todoLists.remove(at: indexSet) 28 | case .moveTodoLists(let indexSet, let index): 29 | state.todoLists.move(from: indexSet, to: index) 30 | } 31 | return state 32 | } 33 | } 34 | 35 | final class TodosReducer: Reducer where State: TodoListStateRoot { 36 | 37 | func reduce(state: State, action: TodosAction) -> State { 38 | let state = state 39 | switch action { 40 | case .addTodo(let id, let text): 41 | state.todoLists[id]?.todos.prepend(TodoItemState(id: UUID().uuidString, text: text)) 42 | case .removeTodos(let id, let indexSet): 43 | state.todoLists[id]?.todos.remove(at: indexSet) 44 | case .moveTodos(let id, let indexSet, let index): 45 | state.todoLists[id]?.todos.move(from: indexSet, to: index) 46 | case .doNothing: 47 | break 48 | } 49 | return state 50 | } 51 | } 52 | 53 | let RootReducer = TodoListsReducer() + TodosReducer() 54 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/TestState/TestState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftDux 3 | 4 | final class TodoListState: Identifiable { 5 | var id: String 6 | var name: String 7 | var todos: OrderedState 8 | 9 | init(id: String, name: String, todos: OrderedState) { 10 | self.id = id 11 | self.name = name 12 | self.todos = todos 13 | } 14 | 15 | static func == (lhs: TodoListState, rhs: TodoListState) -> Bool { 16 | lhs.id == rhs.id 17 | } 18 | } 19 | 20 | struct TodoItemState: Identifiable { 21 | var id: String 22 | var text: String 23 | } 24 | 25 | protocol TodoListStateRoot { 26 | var todoLists: OrderedState { get set } 27 | } 28 | 29 | struct AppState: TodoListStateRoot { 30 | var todoLists: OrderedState 31 | } 32 | 33 | extension AppState { 34 | 35 | static var defaultState: AppState { 36 | AppState( 37 | todoLists: OrderedState( 38 | TodoListState( 39 | id: "123", 40 | name: "Shopping List", 41 | todos: OrderedState( 42 | TodoItemState(id: "1", text: "Eggs"), 43 | TodoItemState(id: "2", text: "Milk"), 44 | TodoItemState(id: "3", text: "Coffee") 45 | ) 46 | ) 47 | ) 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/TestState/TodoList/TodoListReducer.swift: -------------------------------------------------------------------------------- 1 | import SwiftDux 2 | import Foundation 3 | import SwiftUI 4 | 5 | enum TodoListAction: Action { 6 | case addTodo(toList: String, withText: String) 7 | case addTodo2(withText: String) 8 | case removeTodos(fromList: String, at: IndexSet) 9 | case moveTodos(inList: String, from: IndexSet, to: Int) 10 | case doNothing 11 | } 12 | 13 | class TodoListReducer: Reducer { 14 | 15 | func reduce(state: TodoListState, action: TodoListAction) -> TodoListState { 16 | switch action { 17 | case .addTodo(_, let text): fallthrough 18 | case .addTodo2(let text): 19 | state.todos.prepend(TodoItemState(id: UUID().uuidString, text: text)) 20 | case .removeTodos(_, let indexSet): 21 | state.todos.remove(at: indexSet) 22 | case .moveTodos(_, let indexSet, let index): 23 | state.todos.move(from: indexSet, to: index) 24 | case .doNothing: 25 | break 26 | } 27 | return state 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/TestState/configureStore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftDux 3 | 4 | func configureStore(state: AppState = AppState.defaultState) -> Store { 5 | Store(state: state, reducer: TodoListsReducer() + TodosReducer()) 6 | } 7 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/TodoExampleTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Combine 3 | @testable import SwiftDux 4 | 5 | final class TodoExampleTests: XCTestCase { 6 | 7 | func testInitialStateValue() { 8 | let store = configureStore() 9 | XCTAssertEqual(store.state.todoLists.count, 1) 10 | XCTAssertEqual(store.state.todoLists["123"]?.todos.count, 3) 11 | } 12 | 13 | func testAddTodo() { 14 | let store = configureStore() 15 | let name = "My new todo list" 16 | store.send(TodosAction.addTodo(toList: "123", withText: "My new todo list")) 17 | let todoList = store.state.todoLists["123"] 18 | let todo = todoList?.todos.filter { $0.text == name }.first! 19 | XCTAssertEqual(todoList?.todos.values.first?.id, todo?.id) 20 | } 21 | 22 | func testRemoveTodos() { 23 | let store = configureStore() 24 | let lastTodo = store.state.todoLists["123"]?.todos.values.last! 25 | store.send(TodosAction.removeTodos(fromList: "123", at: IndexSet([0, 1]))) 26 | let todoList = store.state.todoLists["123"] 27 | let todo = todoList?.todos.values.first! 28 | XCTAssertEqual(lastTodo?.id, todo?.id) 29 | } 30 | 31 | static var allTests = [ 32 | ("testInitialStateValue", testInitialStateValue), 33 | ("testAddTodo", testAddTodo), 34 | ("testRemoveTodos", testRemoveTodos), 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/UI/ActionBinderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Combine 3 | import SwiftUI 4 | @testable import SwiftDux 5 | 6 | final class ActionBinderTests: XCTestCase { 7 | var store: Store! 8 | var binder: ActionBinder! 9 | 10 | override func setUp() { 11 | self.store = Store(state: TestState(), reducer: TestReducer()) 12 | self.binder = ActionBinder(actionDispatcher: store) 13 | } 14 | 15 | func testInitialState() { 16 | XCTAssertEqual(store.state.name, "") 17 | } 18 | 19 | func testBindingState() { 20 | var binding = binder.bind(store.state.name) { 21 | TestAction.setName($0) 22 | } 23 | binding.wrappedValue = "new value" 24 | XCTAssertEqual(store.state.name, "new value") 25 | } 26 | 27 | func testBindingActionWithNoParameters() { 28 | let binding: ActionBinding<()->()> = binder.bind { TestAction.setName("0") } 29 | binding.wrappedValue() 30 | XCTAssertEqual(store.state.name, "0") 31 | } 32 | 33 | func testBindingActionWithParameters1() { 34 | let binding: ActionBinding<(String)->()> = binder.bind { TestAction.setName($0) } 35 | binding.wrappedValue("1") 36 | XCTAssertEqual(store.state.name, "1") 37 | } 38 | 39 | func testBindingActionWithParameters2() { 40 | let binding: ActionBinding<(String, Int)->()> = binder.bind { TestAction.setName("\($0) \($1)") } 41 | binding.wrappedValue("1", 2) 42 | XCTAssertEqual(store.state.name, "1 2") 43 | } 44 | 45 | func testBindingActionWithParameters3() { 46 | let binding: ActionBinding<(String, Int, Float)->()> = binder.bind { TestAction.setName("\($0) \($1) \($2)") } 47 | binding.wrappedValue("1", 2, 3.1) 48 | XCTAssertEqual(store.state.name, "1 2 3.1") 49 | } 50 | 51 | func testBindingActionWithParameters4() { 52 | let binding: ActionBinding<(String, Int, Float, Color)->()> = binder.bind { TestAction.setName("\($0) \($1) \($2) \($3)") } 53 | binding.wrappedValue("1", 2, 3.1, Color.red) 54 | XCTAssertEqual(store.state.name, "1 2 3.1 red") 55 | } 56 | 57 | func testBindingActionWithParameters5() { 58 | let binding: ActionBinding<(String, Int, Float, Color, CGPoint)->()> = binder.bind { TestAction.setName("\($0) \($1) \($2) \($3) \($4)") } 59 | binding.wrappedValue("1", 2, 3.1, Color.red, CGPoint(x: 10, y: 5)) 60 | XCTAssertEqual(store.state.name, "1 2 3.1 red (10.0, 5.0)") 61 | } 62 | 63 | func testBindingActionWithParameters6() { 64 | let binding: ActionBinding<(String, Int, Float, Color, CGPoint, CGSize)->()> = binder.bind { TestAction.setName("\($0) \($1) \($2) \($3) \($4) \($5)") } 65 | binding.wrappedValue("1", 2, 3.1, Color.red, CGPoint(x: 10, y: 5), CGSize(width: 25, height: 30)) 66 | XCTAssertEqual(store.state.name, "1 2 3.1 red (10.0, 5.0) (25.0, 30.0)") 67 | } 68 | } 69 | 70 | extension ActionBinderTests { 71 | 72 | struct TestState { 73 | var name: String = "" 74 | } 75 | 76 | enum TestAction: Action { 77 | case setName(String) 78 | } 79 | 80 | final class TestReducer: Reducer { 81 | func reduce(state: TestState, action: TestAction) -> TestState { 82 | var state = state 83 | switch action { 84 | case .setName(let name): 85 | state.name = name 86 | } 87 | return state 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/UI/__Snapshots__/ConnectableViewTests/testConnectableView.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StevenLambion/SwiftDux/99cb24dae97ab341b4be6183a01a9c79874d7de5/Tests/SwiftDuxTests/UI/__Snapshots__/ConnectableViewTests/testConnectableView.1.png -------------------------------------------------------------------------------- /Tests/SwiftDuxTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(ActionPlanTests.allTests), 7 | testCase(PerformanceTests.allTests), 8 | testCase(OrderedStateTests.allTests), 9 | testCase(StoreActionDispatcherTests.allTests), 10 | testCase(StoreTests.allTests), 11 | testCase(JSONStatePersistorTests.allTests), 12 | testCase(PersistStateMiddlewareTests.allTests), 13 | testCase(PrintActionMiddlewareTests.allTests), 14 | testCase(TodoExampleTests.allTests), 15 | ] 16 | } 17 | #endif 18 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - Sources/SwiftDux/UI 3 | - Tests 4 | -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | swift-format -r -i Sources 4 | swift-format -r -m lint Sources 5 | 6 | -------------------------------------------------------------------------------- /scripts/jazzy-docs.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | if [ ! -e SwiftDux.xcodeproj ]; then 4 | swift package generate-xcodeproj 5 | fi 6 | 7 | # Set gh-pages branch to the docs directory. 8 | git worktree add docs gh-pages 9 | 10 | rm -rf docs/* 11 | 12 | # Generate documentation 13 | jazzy -x USE_SWIFT_RESPONSE_FILE=NO --module SwiftDux 14 | mkdir -p ./docs/Guides/Images 15 | cp ./Guides/Images/* ./docs/Guides/Images/ 16 | 17 | # Deploy to gh-pages branch locally. 18 | cd ./docs || exit 19 | 20 | git add --all 21 | git commit -m "Documentation changes" 22 | 23 | cd .. 24 | git worktree remove docs 25 | 26 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | # Wrap swift-format, so we can return 1 if there's warnings. 4 | if swift-format -r -m lint Sources 2>&1 | grep 'warning'; then 5 | echo "Linting failed" 6 | exit 1 7 | fi 8 | --------------------------------------------------------------------------------