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 |
--------------------------------------------------------------------------------